diff --git a/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt b/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt index a6d5b60e..75bb756b 100644 --- a/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt +++ b/core/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt @@ -10,7 +10,6 @@ import javax.net.ssl.SSLContext import javax.net.ssl.X509TrustManager import okhttp3.Credentials import okhttp3.OkHttpClient -import okhttp3.Protocol import okhttp3.ResponseBody import okhttp3.logging.HttpLoggingInterceptor import org.moire.ultrasonic.api.subsonic.interceptors.PasswordHexInterceptor @@ -66,8 +65,6 @@ class SubsonicAPIClient( val okHttpClient: OkHttpClient = baseOkClient.newBuilder() // Disable HTTP2 because OkHttp with Exoplayer causes a bug. See https://github.com/square/okhttp/issues/6749 - // TODO Check if the bug is fixed and try to re-enable HTTP2 - .protocols(listOf(Protocol.HTTP_1_1)) .readTimeout(READ_TIMEOUT, MILLISECONDS) .apply { if (config.allowSelfSignedCertificate) allowSelfSignedCertificates() } .addInterceptor { chain -> @@ -98,10 +95,12 @@ class SubsonicAPIClient( .apply { if (config.debug) addLogging() } .build() + val baseUrl = "${config.baseUrl}/rest/" + // Create the Retrofit instance, and register a special converter factory // It will update our protocol version to the correct version, once we made a successful call private val retrofit: Retrofit = Retrofit.Builder() - .baseUrl("${config.baseUrl}/rest/") + .baseUrl(baseUrl) .client(okHttpClient) .addConverterFactory( VersionAwareJacksonConverterFactory.create( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec516055..d6912a5c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ ktlintGradle = "10.2.0" detekt = "1.19.0" preferences = "1.1.1" media = "1.3.1" -media3 = "1.0.0-beta01" +media3 = "1.0.0-beta02" androidSupport = "1.4.0" androidLegacySupport = "1.0.0" diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/APIDataSource.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/APIDataSource.kt deleted file mode 100644 index 4c04f9df..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/APIDataSource.kt +++ /dev/null @@ -1,336 +0,0 @@ -/* - * APIDataSource.kt - * Copyright (C) 2009-2022 Ultrasonic developers - * - * Distributed under terms of the GNU GPLv3 license. - */ - -package org.moire.ultrasonic.playback - -import android.annotation.SuppressLint -import android.net.Uri -import androidx.core.net.toUri -import androidx.media3.common.C -import androidx.media3.common.PlaybackException -import androidx.media3.common.util.Assertions -import androidx.media3.common.util.Util -import androidx.media3.datasource.BaseDataSource -import androidx.media3.datasource.DataSourceException -import androidx.media3.datasource.DataSpec -import androidx.media3.datasource.HttpDataSource -import androidx.media3.datasource.HttpDataSource.HttpDataSourceException -import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException -import androidx.media3.datasource.HttpDataSource.RequestProperties -import androidx.media3.datasource.HttpUtil -import androidx.media3.datasource.TransferListener -import com.google.common.net.HttpHeaders -import java.io.IOException -import java.io.InputStream -import java.io.InterruptedIOException -import okhttp3.Call -import okhttp3.ResponseBody -import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient -import org.moire.ultrasonic.api.subsonic.response.StreamResponse -import org.moire.ultrasonic.api.subsonic.throwOnFailure -import org.moire.ultrasonic.api.subsonic.toStreamResponse -import timber.log.Timber - -/** - * An [HttpDataSource] that delegates to Square's [Call.Factory]. - * - * - * Note: HTTP request headers will be set using all parameters passed via (in order of decreasing - * priority) the `dataSpec`, [.setRequestProperty] and the default parameters used to - * construct the instance. - */ -@SuppressLint("UnsafeOptInUsageError") -@Suppress("MagicNumber") -open class APIDataSource private constructor( - subsonicAPIClient: SubsonicAPIClient -) : BaseDataSource(true), - HttpDataSource { - - /** [DataSource.Factory] for [APIDataSource] instances. */ - class Factory(private var subsonicAPIClient: SubsonicAPIClient) : HttpDataSource.Factory { - private val defaultRequestProperties: RequestProperties = RequestProperties() - private var transferListener: TransferListener? = null - - override fun setDefaultRequestProperties( - defaultRequestProperties: Map - ): Factory { - this.defaultRequestProperties.clearAndSet(defaultRequestProperties) - return this - } - - /** - * Sets the [TransferListener] that will be used. - * - * See [DataSource.addTransferListener]. - * - * @param transferListener The listener that will be used. - * @return This factory. - */ - fun setTransferListener(transferListener: TransferListener?): Factory { - this.transferListener = transferListener - return this - } - - fun setAPIClient(newClient: SubsonicAPIClient) { - this.subsonicAPIClient = newClient - } - - override fun createDataSource(): APIDataSource { - val dataSource = APIDataSource( - subsonicAPIClient - ) - if (transferListener != null) { - dataSource.addTransferListener(transferListener!!) - } - return dataSource - } - } - - private val subsonicAPIClient: SubsonicAPIClient = Assertions.checkNotNull(subsonicAPIClient) - private val requestProperties: RequestProperties = RequestProperties() - private var dataSpec: DataSpec? = null - private var response: retrofit2.Response? = null - private var responseByteStream: InputStream? = null - private var openedNetwork = false - private var bytesToRead: Long = 0 - private var bytesRead: Long = 0 - - override fun getUri(): Uri? { - return when (response) { - null -> null - else -> response!!.raw().request.url.toString().toUri() - } - } - - override fun getResponseCode(): Int { - return if (response == null) -1 else response!!.code() - } - - override fun getResponseHeaders(): Map> { - return if (response == null) emptyMap() else response!!.headers().toMultimap() - } - - override fun setRequestProperty(name: String, value: String) { - Assertions.checkNotNull(name) - Assertions.checkNotNull(value) - requestProperties[name] = value - } - - override fun clearRequestProperty(name: String) { - Assertions.checkNotNull(name) - requestProperties.remove(name) - } - - override fun clearAllRequestProperties() { - requestProperties.clear() - } - - @Suppress("LongMethod", "NestedBlockDepth") - @Throws(HttpDataSourceException::class) - override fun open(dataSpec: DataSpec): Long { - Timber.i( - "APIDatasource: Open: %s %s %s", - dataSpec.uri, - dataSpec.position, - dataSpec.toString() - ) - - this.dataSpec = dataSpec - bytesRead = 0 - bytesToRead = 0 - - transferInitializing(dataSpec) - val components = dataSpec.uri.toString().split('|') - val id = components[0] - val bitrate = components[1].toInt() - val request = subsonicAPIClient.api.stream(id, bitrate, offset = dataSpec.position) - val response: retrofit2.Response? - val streamResponse: StreamResponse - - try { - this.response = request.execute() - response = this.response - streamResponse = response!!.toStreamResponse() - responseByteStream = streamResponse.stream - } catch (e: IOException) { - throw HttpDataSourceException.createForIOException( - e, dataSpec, HttpDataSourceException.TYPE_OPEN - ) - } - - streamResponse.throwOnFailure() - - val responseCode = response.code() - - // Check for a valid response code. - if (!response.isSuccessful) { - if (responseCode == 416) { - val documentSize = - HttpUtil.getDocumentSize(response.headers()[HttpHeaders.CONTENT_RANGE]) - if (dataSpec.position == documentSize) { - openedNetwork = true - transferStarted(dataSpec) - return if (dataSpec.length != C.LENGTH_UNSET.toLong()) dataSpec.length else 0 - } - } - val errorResponseBody: ByteArray = try { - Util.toByteArray(Assertions.checkNotNull(responseByteStream)) - } catch (ignore: IOException) { - Util.EMPTY_BYTE_ARRAY - } - val headers = response.headers().toMultimap() - closeConnectionQuietly() - val cause: IOException? = - if (responseCode == 416) DataSourceException( - PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE - ) else null - throw InvalidResponseCodeException( - responseCode, response.message(), cause, headers, dataSpec, errorResponseBody - ) - } - - // If we requested a range starting from a non-zero position and received a 200 rather than a - // 206, then the server does not support partial requests. We'll need to manually skip to the - // requested position. - val bytesToSkip = - if (responseCode == 200 && dataSpec.position != 0L) dataSpec.position else 0 - - // Determine the length of the data to be read, after skipping. - bytesToRead = if (dataSpec.length != C.LENGTH_UNSET.toLong()) { - dataSpec.length - } else { - val contentLength = response.body()!!.contentLength() - if (contentLength != -1L) contentLength - bytesToSkip else C.LENGTH_UNSET.toLong() - } - openedNetwork = true - transferStarted(dataSpec) - try { - skipFully(bytesToSkip, dataSpec) - } catch (e: HttpDataSourceException) { - closeConnectionQuietly() - throw e - } - - return bytesToRead - } - - @Throws(HttpDataSourceException::class) - override fun read(buffer: ByteArray, offset: Int, length: Int): Int { - // Timber.d("APIDatasource: Read: %s %s", offset, length) - return try { - readInternal(buffer, offset, length) - } catch (e: IOException) { - throw HttpDataSourceException.createForIOException( - e, Util.castNonNull(dataSpec), HttpDataSourceException.TYPE_READ - ) - } - } - - override fun close() { - Timber.i("APIDatasource: Close") - if (openedNetwork) { - openedNetwork = false - transferEnded() - closeConnectionQuietly() - } - } - - /** - * Attempts to skip the specified number of bytes in full. - * - * @param bytesToSkip The number of bytes to skip. - * @param dataSpec The [DataSpec]. - * @throws HttpDataSourceException If the thread is interrupted during the operation, or an error - * occurs while reading from the source, or if the data ended before skipping the specified - * number of bytes. - */ - @Suppress("ThrowsCount") - @Throws(HttpDataSourceException::class) - private fun skipFully(bytesToSkip: Long, dataSpec: DataSpec) { - var bytesToSkipCpy = bytesToSkip - if (bytesToSkipCpy == 0L) { - return - } - val skipBuffer = ByteArray(4096) - try { - while (bytesToSkipCpy > 0) { - val readLength = - bytesToSkipCpy.coerceAtMost(skipBuffer.size.toLong()).toInt() - val read = Util.castNonNull(responseByteStream).read(skipBuffer, 0, readLength) - if (Thread.currentThread().isInterrupted) { - throw InterruptedIOException() - } - if (read == -1) { - throw HttpDataSourceException( - dataSpec, - PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, - HttpDataSourceException.TYPE_OPEN - ) - } - bytesToSkipCpy -= read.toLong() - bytesTransferred(read) - } - return - } catch (e: IOException) { - if (e is HttpDataSourceException) { - throw e - } else { - throw HttpDataSourceException( - dataSpec, - PlaybackException.ERROR_CODE_IO_UNSPECIFIED, - HttpDataSourceException.TYPE_OPEN - ) - } - } - } - - /** - * Reads up to `length` bytes of data and stores them into `buffer`, starting at index - * `offset`. - * - * - * This method blocks until at least one byte of data can be read, the end of the opened range - * is detected, or an exception is thrown. - * - * @param buffer The buffer into which the read data should be stored. - * @param offset The start offset into `buffer` at which data should be written. - * @param readLength The maximum number of bytes to read. - * @return The number of bytes read, or [C.RESULT_END_OF_INPUT] if the end of the opened - * range is reached. - * @throws IOException If an error occurs reading from the source. - */ - @Throws(IOException::class) - private fun readInternal(buffer: ByteArray, offset: Int, readLength: Int): Int { - var readLengthCpy = readLength - if (readLengthCpy == 0) { - return 0 - } - if (bytesToRead != C.LENGTH_UNSET.toLong()) { - val bytesRemaining = bytesToRead - bytesRead - if (bytesRemaining == 0L) { - return C.RESULT_END_OF_INPUT - } - readLengthCpy = readLengthCpy.toLong().coerceAtMost(bytesRemaining).toInt() - } - val read = Util.castNonNull(responseByteStream).read(buffer, offset, readLengthCpy) - if (read == -1) { - return C.RESULT_END_OF_INPUT - } - bytesRead += read.toLong() - bytesTransferred(read) - return read - } - - /** Closes the current connection quietly, if there is one. */ - private fun closeConnectionQuietly() { - if (response != null) { - Assertions.checkNotNull(response!!.body()).close() - response = null - } - responseByteStream = null - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CachedDataSource.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CachedDataSource.kt index 54878505..8bbebc3c 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CachedDataSource.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/CachedDataSource.kt @@ -80,6 +80,7 @@ class CachedDataSource( } // else forward the call to upstream + Timber.d("No cache hit, forwarding call") return upstreamDataSource.open(dataSpec) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt index 9eff1722..0a200060 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt @@ -13,18 +13,20 @@ import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.C.USAGE_MEDIA import androidx.media3.datasource.DataSource +import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession import io.reactivex.rxjava3.disposables.CompositeDisposable +import okhttp3.OkHttpClient import org.koin.core.component.KoinComponent -import org.koin.core.component.inject import org.moire.ultrasonic.activity.NavigationActivity -import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.plusAssign import org.moire.ultrasonic.util.Constants @@ -34,7 +36,6 @@ import timber.log.Timber class PlaybackService : MediaLibraryService(), KoinComponent { private lateinit var player: ExoPlayer private lateinit var mediaLibrarySession: MediaLibrarySession - private lateinit var apiDataSource: APIDataSource.Factory private lateinit var librarySessionCallback: MediaLibrarySession.Callback @@ -82,17 +83,33 @@ class PlaybackService : MediaLibraryService(), KoinComponent { UApp.instance!!.shutdownKoin() } - @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private val resolver: ResolvingDataSource.Resolver = ResolvingDataSource.Resolver { + val components = it.uri.toString().split('|') + val id = components[0] + val bitrate = components[1].toInt() + val uri = getMusicService().getStreamUrl(id, bitrate, null)!! + // AirSonic doesn't seem to stream correctly with the default + // icy-metadata headers set by media3, so remove them. + it.buildUpon().setUri(uri).setHttpRequestHeaders(emptyMap()).build() + } + private fun initializeSessionAndPlayer() { if (isStarted) return setMediaNotificationProvider(MediaNotificationProvider(UApp.applicationContext())) - val subsonicAPIClient: SubsonicAPIClient by inject() + // Create a new plain OkHttpClient + val builder = OkHttpClient.Builder() + val client = builder.build() - // Create a MediaSource which passes calls through our OkHttp Stack - apiDataSource = APIDataSource.Factory(subsonicAPIClient) - val cacheDataSourceFactory: DataSource.Factory = CachedDataSource.Factory(apiDataSource) + // Create the wrapped data sources: + // CachedDataSource is the first. If it cannot find a file, + // it will forward to ResolvingDataSource, which will create a URL through the resolver + // and pass it onto the OkHttpDataSource. + val okHttpDataSource = OkHttpDataSource.Factory(client) + val resolvingDataSource = ResolvingDataSource.Factory(okHttpDataSource, resolver) + val cacheDataSourceFactory: DataSource.Factory = + CachedDataSource.Factory(resolvingDataSource) // Create a renderer with HW rendering support val renderer = DefaultRenderersFactory(this) @@ -125,9 +142,6 @@ class PlaybackService : MediaLibraryService(), KoinComponent { // Set a listener to update the API client when the active server has changed rxBusSubscription += RxBus.activeServerChangeObservable.subscribe { - val newClient: SubsonicAPIClient by inject() - apiDataSource.setAPIClient(newClient) - // Set the player wake mode player.setWakeMode(getWakeModeFlag()) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt index 9386df37..34af142b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/CachedMusicService.kt @@ -312,8 +312,8 @@ class CachedMusicService(private val musicService: MusicService) : MusicService, } @Throws(Exception::class) - override fun getVideoUrl(id: String): String? { - return musicService.getVideoUrl(id) + override fun getStreamUrl(id: String, maxBitRate: Int?, format: String?): String? { + return musicService.getStreamUrl(id, maxBitRate, format) } @Throws(Exception::class) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt index 2099527c..ea9ed883 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MusicService.kt @@ -136,7 +136,7 @@ interface MusicService { ): Pair @Throws(Exception::class) - fun getVideoUrl(id: String): String? + fun getStreamUrl(id: String, maxBitRate: Int?, format: String?): String? @Throws(Exception::class) fun updateJukeboxPlaylist(ids: List?): JukeboxStatus diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt index 5d651054..5c438218 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/OfflineMusicService.kt @@ -425,8 +425,8 @@ class OfflineMusicService : MusicService, KoinComponent { } @Throws(OfflineException::class) - override fun getVideoUrl(id: String): String? { - throw OfflineException("getVideoUrl isn't available in offline mode") + override fun getStreamUrl(id: String, maxBitRate: Int?, format: String?): String? { + throw OfflineException("getStreamUrl isn't available in offline mode") } @Throws(OfflineException::class) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt index b2a53e75..5223c641 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RESTMusicService.kt @@ -467,9 +467,28 @@ open class RESTMusicService( * call because that could take a long time. */ @Throws(Exception::class) - override fun getVideoUrl( - id: String + override fun getStreamUrl( + id: String, + maxBitRate: Int?, + format: String? ): String { + Timber.i("Start") + + // Get the request from Retrofit, but don't execute it! + val request = API.stream(id).request() + + // Create a new call with the request, and execute ist on our custom client + val response = streamClient.newCall(request).execute() + + // The complete url :) + val url = response.request.url + + Timber.i("Done") + + return url.toString() + } + + val streamClient by lazy { // Create a new modified okhttp client to intercept the URL val builder = subsonicAPIClient.okHttpClient.newBuilder() @@ -485,18 +504,7 @@ open class RESTMusicService( } // Create a new Okhttp client - val client = builder.build() - - // Get the request from Retrofit, but don't execute it! - val request = API.stream(id, format = "raw").request() - - // Create a new call with the request, and execute ist on our custom client - val response = client.newCall(request).execute() - - // The complete url :) - val url = response.request.url - - return url.toString() + builder.build() } @Throws(Exception::class) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/VideoPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/VideoPlayer.kt index efd77833..6ad3fe0e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/VideoPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/VideoPlayer.kt @@ -21,7 +21,11 @@ class VideoPlayer { } try { val intent = Intent(Intent.ACTION_VIEW) - val url = MusicServiceFactory.getMusicService().getVideoUrl(track.id) + val url = MusicServiceFactory.getMusicService().getStreamUrl( + track.id, + maxBitRate = null, + format = "raw" + ) intent.setDataAndType( Uri.parse(url), "video/*"