diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 861dfa036..25c64c796 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -40,14 +40,14 @@ kotlin { jvmToolchain(11) } android { namespace = "com.github.livingwithhippos.unchained" - compileSdk = 36 + compileSdk = 37 defaultConfig { applicationId = "com.github.livingwithhippos.unchained" minSdk = 27 - targetSdk = 36 - versionCode = 58 - versionName = "1.6.1" + targetSdk = 37 + versionCode = 59 + versionName = "1.7.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/RemoteServiceEntity.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/RemoteServiceEntity.kt index b600cb4f8..e86129cc4 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/RemoteServiceEntity.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/local/RemoteServiceEntity.kt @@ -66,6 +66,8 @@ sealed class RemoteServiceType( data object VLC : RemoteServiceType(1, true, R.string.player_vlc, R.drawable.icon_vlc) data object JACKETT : RemoteServiceType(2, false, R.string.jackett, R.drawable.icon_jackett) + + data object PROWLARR : RemoteServiceType(3, false, R.string.prowlarr, R.drawable.icon_prowlarr) } val serviceTypeMap = @@ -73,6 +75,7 @@ val serviceTypeMap = RemoteServiceType.KODI.value to RemoteServiceType.KODI, RemoteServiceType.VLC.value to RemoteServiceType.VLC, RemoteServiceType.JACKETT.value to RemoteServiceType.JACKETT, + RemoteServiceType.PROWLARR.value to RemoteServiceType.PROWLARR, ) /** Helper class to have all the service details together */ diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Prowlarr.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Prowlarr.kt new file mode 100644 index 000000000..1b10cb759 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/Prowlarr.kt @@ -0,0 +1,91 @@ +package com.github.livingwithhippos.unchained.data.model + +import android.content.Context +import com.github.livingwithhippos.unchained.plugins.model.ScrapedItem +import com.github.livingwithhippos.unchained.utilities.HASH_PATTERN +import com.github.livingwithhippos.unchained.utilities.MAGNET_PATTERN +import com.github.livingwithhippos.unchained.utilities.extension.getFileSizeString +import java.util.regex.Pattern +import kotlinx.serialization.Serializable +import timber.log.Timber + +private val magnetPattern = Pattern.compile(MAGNET_PATTERN) +private val hashPattern = Regex(HASH_PATTERN) + +@Serializable +data class ProwlarrResponse( + val guid: String, + val age: Int, + val ageHours: Double, + val ageMinutes: Double, + val size: Long, + val files: Int? = null, + val indexerId: Int, + val indexer: String, + val title: String, + val sortTitle: String, + val imdbId: Int? = null, + val tmdbId: Int? = null, + val tvdbId: Int? = null, + val tvMazeId: Int? = null, + val publishDate: String, + val infoUrl: String, + val indexerFlags: List = emptyList(), + val categories: List = emptyList(), + val downloadUrl: String? = null, + val magnetUrl: String? = null, + val infoHash: String? = null, + val seeders: Int, + val leechers: Int, + val protocol: String, + val fileName: String, +) + +@Serializable +data class PCategory( + val id: Int, + val name: String? = null, + val subCategories: List = emptyList(), +) + +fun prowlarrToScrapedItems(context: Context, response: List): List { + return response.mapNotNull { + when (it.protocol) { + "torrent", + "magnet" -> { + // todo: sometimes indexers have no magnet links, and the downloadUtl + // need to be followed because it returns a magnet link + // (and maybe some of them returns a torrent file) + // the user can still long click on the result and manually follow the link + // so we still shown it + + var magnet: String? = null + if (magnetPattern.matcher(it.guid).lookingAt()) magnet = it.guid + else if (it.magnetUrl != null && magnetPattern.matcher(it.magnetUrl).lookingAt()) + magnet = it.magnetUrl + else if (it.infoHash != null && hashPattern.matches(it.infoHash)) + magnet = "magnet:?xt=urn:btih:${it.infoHash}" + else if (it.magnetUrl != null) magnet = it.magnetUrl + + ScrapedItem( + name = it.title, + link = it.infoUrl, + size = getFileSizeString(context, it.size), + addedDate = it.publishDate, + parsedSize = it.size.toDouble(), + seeders = it.seeders.toString(), + leechers = it.leechers.toString(), + magnets = if (magnet != null) listOf(magnet) else emptyList(), + torrents = if (it.downloadUrl != null) listOf(it.downloadUrl) else emptyList(), + hosting = emptyList(), + ) + } + else -> { + Timber.e( + "Don't know how to handle Prowlarr response with protocol ${it.protocol}, $it" + ) + null + } + } + } +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/torznab/TZSearch.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/torznab/TZSearch.kt index f8b85822c..fe7d952ef 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/torznab/TZSearch.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/model/torznab/TZSearch.kt @@ -6,6 +6,8 @@ import com.github.livingwithhippos.unchained.utilities.directChild import com.github.livingwithhippos.unchained.utilities.directChildText import com.github.livingwithhippos.unchained.utilities.directChildren import com.github.livingwithhippos.unchained.utilities.extension.getFileSizeString +import com.github.livingwithhippos.unchained.utilities.extension.isMagnet +import com.github.livingwithhippos.unchained.utilities.extension.isTorrent import com.github.livingwithhippos.unchained.utilities.parseCommonSize import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -99,28 +101,49 @@ fun rssToScrapedItems(context: Context, rss: SearchRSS): Pair, rss.channel.items.forEach { item -> try { val sizeLong = item.size.toLongOrNull() - scraped.add( - ScrapedItem( - name = item.title, - link = item.comments, - seeders = - item.torznabAttributes - .firstOrNull { it.name.equals("seeders", ignoreCase = true) } - ?.value, - leechers = - item.torznabAttributes - .firstOrNull { it.name.equals("leechers", ignoreCase = true) } - ?.value, - size = - if (sizeLong !== null) getFileSizeString(context, sizeLong) else item.size, - addedDate = item.pubDate, - parsedSize = parseCommonSize(item.size), - // todo: add better recognition of links - magnets = listOf(item.link), - torrents = emptyList(), - hosting = emptyList(), + + var magnet: String? + var torrent: String? = null + + magnet = + item.torznabAttributes + .firstOrNull { it.name.equals("magneturl", ignoreCase = true) } + ?.value + if (magnet == null) { + if (item.link.isMagnet()) magnet = item.link + else if (item.guid.isMagnet()) magnet = item.guid + else if (item.enclosure.url.isMagnet()) magnet = item.enclosure.url + } + + if (item.link.isTorrent()) torrent = item.link + else if (item.guid.isTorrent()) torrent = item.guid + else if (item.enclosure.url.isTorrent()) torrent = item.enclosure.url + + if (magnet != null || torrent != null) { + scraped.add( + ScrapedItem( + name = item.title, + link = item.comments, + seeders = + item.torznabAttributes + .firstOrNull { it.name.equals("seeders", ignoreCase = true) } + ?.value, + leechers = + item.torznabAttributes + .firstOrNull { it.name.equals("leechers", ignoreCase = true) } + ?.value, + size = + if (sizeLong !== null) getFileSizeString(context, sizeLong) + else item.size, + addedDate = item.pubDate, + parsedSize = parseCommonSize(item.size), + // todo: add better recognition of links + magnets = if (magnet != null) listOf(magnet) else emptyList(), + torrents = if (torrent != null) listOf(torrent) else emptyList(), + hosting = emptyList(), + ) ) - ) + } } catch (ex: Exception) { Timber.e(ex) errors++ diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/JackettRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/JackettRepository.kt index 4995ef8a3..32e29acc6 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/JackettRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/JackettRepository.kt @@ -128,7 +128,7 @@ constructor( emit(ParserResult.Results(items.first)) return@flow } catch (ex: Exception) { - Timber.e(ex, "Error parsing Search response") + Timber.e(ex, "Error parsing Jackett search response") } emit(ParserResult.SourceError) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiRepository.kt index cf03d8dd2..8a8c097e7 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiRepository.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/KodiRepository.kt @@ -70,6 +70,35 @@ constructor(protoStore: ProtoStore, @param:ClassicClient private val client: OkH } } + suspend fun getVolume( + address: String, + username: String? = null, + password: String? = null, + ): KodiGenericResponse? { + try { + val kodiApiHelper: KodiApiHelper = provideApiHelper(address) + val kodiResponse = + safeApiCall( + call = { + kodiApiHelper.getVolume( + request = + KodiRequest( + method = "Application.GetProperties", + params = KodiParams(properties = listOf("volume")), + ), + auth = encodeAuthentication(username, password), + ) + }, + errorMessage = "Error getting Kodi volume", + ) + + return kodiResponse + } catch (e: Exception) { + Timber.e(e) + return null + } + } + suspend fun openUrl( baseUrl: String, port: Int, diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/ProwlarrRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/ProwlarrRepository.kt new file mode 100644 index 000000000..9fdfc78c7 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/ProwlarrRepository.kt @@ -0,0 +1,105 @@ +package com.github.livingwithhippos.unchained.data.repository + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import com.github.livingwithhippos.unchained.data.local.CompleteRemoteService +import com.github.livingwithhippos.unchained.data.model.ProwlarrResponse +import com.github.livingwithhippos.unchained.data.model.prowlarrToScrapedItems +import com.github.livingwithhippos.unchained.di.ClassicClient +import com.github.livingwithhippos.unchained.plugins.ParserResult +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber + +class ProwlarrRepository +@Inject +constructor( + @param:ClassicClient private val client: OkHttpClient, + @ApplicationContext private val applicationContext: Context, +) { + // todo: implement POST search with json body, as it is more flexible and allows to bypass url + // length limits + // https://prowlarr.com/docs/api/#/Search/post_api_v1_search + + private val json = Json { ignoreUnknownKeys = true } + + private fun getBasicApi(service: CompleteRemoteService): Uri.Builder? { + return try { + val baseUri = service.address.toUri() + if (baseUri.scheme.isNullOrBlank() || baseUri.encodedAuthority.isNullOrBlank()) { + return null + } + + baseUri + .buildUpon() + .encodedQuery(null) + .fragment(null) + .appendPath("api") + .appendPath("v1") + .appendPath("search") + .appendQueryParameter("apikey", service.apiToken) + } catch (ex: Exception) { + Timber.e(ex, "Error parsing url from $service") + null + } + } + + fun performSearch( + service: CompleteRemoteService, + query: String, + indexers: List = emptyList(), + categories: List = emptyList(), + offset: Int? = null, + limit: Int? = null, + ) = + flow { + val builder = getBasicApi(service) + if (builder == null) { + emit(ParserResult.SourceError) + return@flow + } + + builder.appendQueryParameter("query", query) + if (indexers.isNotEmpty()) { + indexers.forEach { builder.appendQueryParameter("indexerIds", it.toString()) } + } + if (categories.isNotEmpty()) { + categories.forEach { builder.appendQueryParameter("categories", it.toString()) } + } + + if (offset != null) builder.appendQueryParameter("offset", offset.toString()) + if (limit != null) builder.appendQueryParameter("limit", limit.toString()) + + val request = Request.Builder().url(builder.build().toString()).build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + emit(ParserResult.SourceError) + return@flow + } + if (response.body == null) { + emit(ParserResult.NetworkBodyError) + return@flow + } + val body: String = response.body.string() + try { + val results: List = json.decodeFromString(body) + val items = prowlarrToScrapedItems(applicationContext, results) + emit(ParserResult.Results(items)) + return@flow + } catch (ex: Exception) { + Timber.e(ex, "Error parsing Prowlarr search response") + } + + emit(ParserResult.SourceError) + } + } + .flowOn(Dispatchers.IO) +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/RemoteRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/RemoteRepository.kt deleted file mode 100644 index e7d5fbe9f..000000000 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/RemoteRepository.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.github.livingwithhippos.unchained.data.repository - -import com.github.livingwithhippos.unchained.di.ClassicClient -import com.github.livingwithhippos.unchained.utilities.EitherResult -import com.github.livingwithhippos.unchained.utilities.addHttpScheme -import java.io.IOException -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request -import timber.log.Timber - -class RemoteRepository @Inject constructor(@param:ClassicClient private val client: OkHttpClient) { - // todo: add https://mpv.io/manual/stable/#json-ipc - - suspend fun openUrl( - baseUrl: String, - port: Int = 9090, - url: String, - username: String? = null, - password: String? = null, - ): EitherResult = - withContext(Dispatchers.IO) { - // https://wiki.videolan.org/Documentation:Modules/http_intf/#VLC_2.0.0_and_later - // needs a password or it won't work: - // vlc --http-host 0.0.0.0 --http-port 9090 --http-password pass - // also on some linux distro there may be a bug crashing the app 'glconv_vaapi_x11 gl - // error: vaInitialize: unknown libva error' - // workaround with export LIBVA_DRIVER_NAME=nvidia - val credential = okhttp3.Credentials.basic(username ?: "", password ?: "") - val request = - Request.Builder() - .url( - "${addHttpScheme(baseUrl)}:$port/requests/status.xml?command=in_play&input=$url" - ) - .header("Authorization", credential) - .build() - - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) - return@withContext EitherResult.Failure( - IOException("Unexpected http code $response") - ) - - Timber.d(response.body!!.string()) - return@withContext EitherResult.Success(true) - } - } - - suspend fun openUrl( - baseUrl: String, - url: String, - username: String? = null, - password: String? = null, - ): EitherResult = - withContext(Dispatchers.IO) { - val credential = okhttp3.Credentials.basic(username ?: "", password ?: "") - val newBaseUrl = if (baseUrl.endsWith("/")) baseUrl.dropLast(1) else baseUrl - val request = - Request.Builder() - .url("$newBaseUrl/requests/status.xml?command=in_play&input=$url") - .header("Authorization", credential) - .build() - - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) - return@withContext EitherResult.Failure( - IOException("Unexpected http code $response") - ) - - Timber.d(response.body!!.string()) - return@withContext EitherResult.Success(true) - } - } -} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/VLCRemoteRepository.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/VLCRemoteRepository.kt new file mode 100644 index 000000000..6b698e502 --- /dev/null +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/data/repository/VLCRemoteRepository.kt @@ -0,0 +1,117 @@ +package com.github.livingwithhippos.unchained.data.repository + +import com.github.livingwithhippos.unchained.di.ClassicClient +import com.github.livingwithhippos.unchained.utilities.EitherResult +import com.github.livingwithhippos.unchained.utilities.addHttpScheme +import java.io.IOException +import java.net.URLEncoder +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber + +class VLCRemoteRepository +@Inject +constructor(@param:ClassicClient private val client: OkHttpClient) { + // todo: add https://mpv.io/manual/stable/#json-ipc + + suspend fun openUrl( + baseUrl: String, + port: Int = 9090, + url: String, + username: String? = null, + password: String? = null, + ): EitherResult = + withContext(Dispatchers.IO) { + // https://wiki.videolan.org/Documentation:Modules/http_intf/#VLC_2.0.0_and_later + // needs a password, or it won't work: + // vlc --http-host 0.0.0.0 --http-port 9090 --http-password pass + // important! enable the web interface in the settings or the command won't print errors + // but also won't work! + // also on some linux distro there may be a bug crashing the app 'glconv_vaapi_x11 gl + // error: vaInitialize: unknown libva error' + // workaround with export LIBVA_DRIVER_NAME=nvidia + val credential = okhttp3.Credentials.basic(username ?: "", password ?: "") + val request = + Request.Builder() + .url( + "${addHttpScheme(baseUrl)}:$port/requests/status.xml?command=in_play&input=$url" + ) + .header("Authorization", credential) + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) + return@withContext EitherResult.Failure( + IOException("Unexpected http code $response") + ) + + return@withContext EitherResult.Success(true) + } + } + + suspend fun openUrl( + baseUrl: String, + url: String, + username: String? = null, + password: String? = null, + encodeUrl: Boolean = true, + ): EitherResult = + withContext(Dispatchers.IO) { + try { + val credential = okhttp3.Credentials.basic(username ?: "", password ?: "") + val newBaseUrl = + addHttpScheme(if (baseUrl.endsWith("/")) baseUrl.dropLast(1) else baseUrl) + val finalUrl = if (encodeUrl) URLEncoder.encode(url, "UTF-8") else url + val request = + Request.Builder() + .url("$newBaseUrl/requests/status.xml?command=in_play&input=$finalUrl") + .header("Authorization", credential) + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) + return@withContext EitherResult.Failure( + IOException("Unexpected http code $response") + ) + + return@withContext EitherResult.Success(true) + } + } catch (e: Exception) { + Timber.e(e, "Error opening VLC remote url") + return@withContext EitherResult.Failure(e) + } + } + + suspend fun getPlayList( + baseUrl: String, + username: String? = null, + password: String? = null, + ): EitherResult = + withContext(Dispatchers.IO) { + try { + val credential = okhttp3.Credentials.basic(username ?: "", password ?: "") + val newBaseUrl = + addHttpScheme(if (baseUrl.endsWith("/")) baseUrl.dropLast(1) else baseUrl) + val request = + Request.Builder() + .url("$newBaseUrl/requests/playlist.xml") + .header("Authorization", credential) + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) + return@withContext EitherResult.Failure( + IOException("Unexpected http code $response") + ) + + return@withContext EitherResult.Success(true) + } + } catch (e: Exception) { + Timber.e(e, "Error testing VLC service") + return@withContext EitherResult.Failure(e) + } + } +} diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/DownloadDetailsFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/DownloadDetailsFragment.kt index 2dee71705..012d442c9 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/DownloadDetailsFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/view/DownloadDetailsFragment.kt @@ -367,14 +367,14 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { if (recentServiceItem != null) { val serviceType: RemoteServiceType = - getServiceType(recentServiceItem.type)!! + serviceTypeMap[recentServiceItem.type]!! playOnService(url ?: args.details.download, recentServiceItem, serviceType) } } R.id.default_service -> { if (defaultService != null) { - val serviceType: RemoteServiceType = getServiceType(defaultService.type)!! + val serviceType: RemoteServiceType = serviceTypeMap[defaultService.type]!! playOnService(url ?: args.details.download, defaultService, serviceType) } } @@ -423,7 +423,7 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { popup.contentView.findViewById(R.id.defaultServiceLayout) if (defaultService != null) { - val serviceType: RemoteServiceType? = getServiceType(defaultService.type) + val serviceType: RemoteServiceType? = serviceTypeMap[defaultService.type] if (serviceType != null) { defaultLayout .findViewById(R.id.serviceIcon) @@ -451,7 +451,7 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { service.id == recentService } if (recentServiceItem != null) { - val serviceType: RemoteServiceType? = getServiceType(recentServiceItem.type) + val serviceType: RemoteServiceType? = serviceTypeMap[recentServiceItem.type] if (serviceType != null) { recentLayout .findViewById(R.id.recentServiceIcon) @@ -684,18 +684,6 @@ class DownloadDetailsFragment : UnchainedFragment(), DownloadDetailsListener { } } - private fun getServiceType(type: Int): RemoteServiceType? { - return when (type) { - RemoteServiceType.KODI.value -> RemoteServiceType.KODI - RemoteServiceType.VLC.value -> RemoteServiceType.VLC - RemoteServiceType.JACKETT.value -> RemoteServiceType.JACKETT - else -> { - Timber.e("Unknown service type $type") - null - } - } - } - companion object { const val SHOW_SHARE_BUTTON = "show_share_button" const val SHOW_OPEN_BUTTON = "show_open_button" diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/viewmodel/DownloadDetailsViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/viewmodel/DownloadDetailsViewModel.kt index 624b9a3e9..1f4f71cf2 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/viewmodel/DownloadDetailsViewModel.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/downloaddetails/viewmodel/DownloadDetailsViewModel.kt @@ -12,9 +12,9 @@ import com.github.livingwithhippos.unchained.data.model.KodiDevice import com.github.livingwithhippos.unchained.data.model.Stream import com.github.livingwithhippos.unchained.data.repository.DownloadRepository import com.github.livingwithhippos.unchained.data.repository.KodiRepository -import com.github.livingwithhippos.unchained.data.repository.RemoteRepository import com.github.livingwithhippos.unchained.data.repository.ServiceRepository import com.github.livingwithhippos.unchained.data.repository.StreamingRepository +import com.github.livingwithhippos.unchained.data.repository.VLCRemoteRepository import com.github.livingwithhippos.unchained.utilities.EitherResult import com.github.livingwithhippos.unchained.utilities.Event import com.github.livingwithhippos.unchained.utilities.postEvent @@ -33,7 +33,7 @@ constructor( private val streamingRepository: StreamingRepository, private val downloadRepository: DownloadRepository, private val kodiRepository: KodiRepository, - private val remoteServiceRepository: RemoteRepository, + private val remoteServiceRepository: VLCRemoteRepository, private val serviceRepository: ServiceRepository, ) : ViewModel() { diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteServiceFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteServiceFragment.kt index 4702a1557..440e792ad 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteServiceFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/view/RemoteServiceFragment.kt @@ -134,6 +134,21 @@ class RemoteServiceFragment : Fragment() { ) viewModel.updateService(remoteService) } + RemoteServiceType.PROWLARR -> { + val remoteService = + RemoteService( + id = serviceId, + device = deviceID, + name = name, + port = port, + username = username.ifBlank { null }, + password = password.ifBlank { null }, + type = serviceType.value, + apiToken = apiToken, + isDefault = false, + ) + viewModel.updateService(remoteService) + } RemoteServiceType.KODI -> { val remoteService = @@ -232,6 +247,10 @@ class RemoteServiceFragment : Fragment() { RemoteServiceType.JACKETT } + getString(R.string.prowlarr) -> { + RemoteServiceType.PROWLARR + } + else -> { null } @@ -265,6 +284,12 @@ class RemoteServiceFragment : Fragment() { binding.tfApiToken.visibility = View.VISIBLE } + RemoteServiceType.PROWLARR -> { + binding.switchDefault.isEnabled = false + binding.switchDefault.isChecked = false + binding.tfApiToken.visibility = View.VISIBLE + } + null -> { Timber.e("Unknown service type $type") return diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/viewmodel/DeviceViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/viewmodel/DeviceViewModel.kt index 8297c2353..e816fd535 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/viewmodel/DeviceViewModel.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remotedevice/viewmodel/DeviceViewModel.kt @@ -37,6 +37,36 @@ constructor( viewModelScope.launch { withContext(Dispatchers.IO) { when (type) { + is RemoteServiceType.PROWLARR -> { + val url: StringBuilder = StringBuilder() + if ( + !address.startsWith("http://", ignoreCase = true) && + !address.startsWith("https://", ignoreCase = true) + ) { + if (port == 443) url.append("https://") else url.append("http://") + } + url.append(address) + if (port != 80 && port != 443) url.append(":$port") + url.append("/api") + if (apiToken != null) url.append("&apikey=$apiToken") + val request = okhttp3.Request.Builder().url(url.toString()).build() + try { + val response = client.newCall(request).execute() + if (response.isSuccessful) { + Timber.d(response.body.toString()) + deviceLiveData.postValue(DeviceEvent.ServiceWorking) + } else { + deviceLiveData.postValue( + DeviceEvent.ServiceNotWorking(ServiceErrorType.ResponseError) + ) + } + } catch (e: Exception) { + Timber.e(e, "Error testing the service $url") + deviceLiveData.postValue( + DeviceEvent.ServiceNotWorking(ServiceErrorType.Generic) + ) + } + } is RemoteServiceType.JACKETT -> { val url: StringBuilder = StringBuilder() if ( diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceFragment.kt index 2b7c9b148..0e7000634 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/view/CompleteServiceFragment.kt @@ -132,6 +132,21 @@ class CompleteServiceFragment : Fragment() { viewModel.updateService(remoteService) } + RemoteServiceType.PROWLARR -> { + val remoteService = + CompleteRemoteService( + id = serviceId, + name = name, + address = address, + username = username.ifBlank { null }, + password = password.ifBlank { null }, + type = serviceType.value, + apiToken = apiToken, + isDefault = false, + ) + viewModel.updateService(remoteService) + } + RemoteServiceType.KODI -> { val remoteService = CompleteRemoteService( @@ -232,6 +247,9 @@ class CompleteServiceFragment : Fragment() { getString(R.string.jackett) -> { RemoteServiceType.JACKETT } + getString(R.string.prowlarr) -> { + RemoteServiceType.PROWLARR + } else -> { null @@ -265,6 +283,12 @@ class CompleteServiceFragment : Fragment() { binding.tfApiToken.visibility = View.VISIBLE } + RemoteServiceType.PROWLARR -> { + binding.switchDefault.isEnabled = false + binding.switchDefault.isChecked = false + binding.tfApiToken.visibility = View.VISIBLE + } + null -> { Timber.e("Unknown service type $type") return diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/viewmodel/ServiceViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/viewmodel/ServiceViewModel.kt index 6efc69ae3..0aa4d3161 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/viewmodel/ServiceViewModel.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/remoteservice/viewmodel/ServiceViewModel.kt @@ -5,9 +5,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.livingwithhippos.unchained.data.local.CompleteRemoteService import com.github.livingwithhippos.unchained.data.local.RemoteServiceType +import com.github.livingwithhippos.unchained.data.repository.KodiRepository import com.github.livingwithhippos.unchained.data.repository.ServiceRepository +import com.github.livingwithhippos.unchained.data.repository.VLCRemoteRepository import com.github.livingwithhippos.unchained.di.ClassicClient import com.github.livingwithhippos.unchained.remotedevice.viewmodel.ServiceErrorType +import com.github.livingwithhippos.unchained.utilities.EitherResult import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers @@ -21,6 +24,8 @@ class ServiceViewModel @Inject constructor( private val serviceRepository: ServiceRepository, + private val kodiRepository: KodiRepository, + private val vlcRemoteRepository: VLCRemoteRepository, @param:ClassicClient private val client: OkHttpClient, ) : ViewModel() { @@ -69,16 +74,43 @@ constructor( } } + is RemoteServiceType.PROWLARR -> { + val url: StringBuilder = StringBuilder() + url.append(address) + url.append("/api") + if (apiToken != null) url.append("&apikey=$apiToken") + val request = okhttp3.Request.Builder().url(url.toString()).build() + try { + val response = client.newCall(request).execute() + if (response.isSuccessful) { + Timber.d(response.body.toString()) + serviceLiveData.postValue(ServiceEvent.ServiceWorking) + } else { + serviceLiveData.postValue( + ServiceEvent.ServiceNotWorking(ServiceErrorType.ResponseError) + ) + } + } catch (e: Exception) { + Timber.e(e, "Error testing the service $url") + serviceLiveData.postValue( + ServiceEvent.ServiceNotWorking(ServiceErrorType.Generic) + ) + } + } + is RemoteServiceType.KODI -> { - // todo: implement or manage from caller + val response = kodiRepository.getVolume(address, username, password) serviceLiveData.postValue( - ServiceEvent.ServiceNotWorking(ServiceErrorType.InvalidService) + if (response != null) ServiceEvent.ServiceWorking + else ServiceEvent.ServiceNotWorking(ServiceErrorType.ResponseError) ) } is RemoteServiceType.VLC -> { + val response = vlcRemoteRepository.getPlayList(address, username, password) serviceLiveData.postValue( - ServiceEvent.ServiceNotWorking(ServiceErrorType.InvalidService) + if (response is EitherResult.Success) ServiceEvent.ServiceWorking + else ServiceEvent.ServiceNotWorking(ServiceErrorType.ResponseError) ) } } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/PluginSearchFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/PluginSearchFragment.kt index d3adb9fd3..4e86b50f8 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/PluginSearchFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/PluginSearchFragment.kt @@ -239,6 +239,15 @@ class PluginSearchFragment : UnchainedFragment(), SearchItemListener { val adapter = SearchItemAdapter(this) binding.rvSearchList.adapter = adapter + // Restore any previously saved search results from the ViewModel so results + // are preserved when navigating to a detail and back (or on configuration change). + val savedResults = viewModel.getSearchResults() + if (savedResults.isNotEmpty()) { + searchResultsList.clear() + searchResultsList.addAll(savedResults) + submitSortedList(adapter, searchResultsList) + } + binding.bStartSearch.setOnClickListener { val query: String = binding.tiSearch.text?.toString()?.trim() ?: "" if (query.isBlank()) { diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/SearchItemFragment.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/SearchItemFragment.kt index fce0473f6..5e21f6fa8 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/SearchItemFragment.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/view/SearchItemFragment.kt @@ -12,7 +12,6 @@ import com.github.livingwithhippos.unchained.plugins.model.ScrapedItem import com.github.livingwithhippos.unchained.search.model.LinkItem import com.github.livingwithhippos.unchained.search.model.LinkItemAdapter import com.github.livingwithhippos.unchained.search.model.LinkItemListener -import com.github.livingwithhippos.unchained.utilities.MAGNET_PATTERN import com.github.livingwithhippos.unchained.utilities.extension.copyToClipboard import com.github.livingwithhippos.unchained.utilities.extension.openExternalWebPage import com.github.livingwithhippos.unchained.utilities.extension.showToast @@ -23,8 +22,6 @@ class SearchItemFragment : UnchainedFragment(), LinkItemListener { private val args: SearchItemFragmentArgs by navArgs() - private val magnetPattern = Regex(MAGNET_PATTERN, RegexOption.IGNORE_CASE) - private var _binding: FragmentSearchItemBinding? = null // This property is only valid between onCreateView and onDestroyView. diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/viewmodel/SearchViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/viewmodel/SearchViewModel.kt index 078926372..8ff0e5071 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/search/viewmodel/SearchViewModel.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/search/viewmodel/SearchViewModel.kt @@ -13,6 +13,7 @@ import com.github.livingwithhippos.unchained.data.local.RemoteServiceType import com.github.livingwithhippos.unchained.data.repository.DatabasePluginRepository import com.github.livingwithhippos.unchained.data.repository.JackettRepository import com.github.livingwithhippos.unchained.data.repository.PluginRepository +import com.github.livingwithhippos.unchained.data.repository.ProwlarrRepository import com.github.livingwithhippos.unchained.data.repository.ServiceRepository import com.github.livingwithhippos.unchained.folderlist.view.FolderListFragment import com.github.livingwithhippos.unchained.folderlist.viewmodel.FolderListViewModel @@ -45,6 +46,7 @@ constructor( private val databasePluginsRepository: DatabasePluginRepository, private val serviceRepository: ServiceRepository, private val jackettRepository: JackettRepository, + private val prowlarrRepository: ProwlarrRepository, private val parser: Parser, ) : ViewModel() { @@ -136,7 +138,10 @@ constructor( plugin.copy(selected = selectedPlugins.contains(plugin.name.lowercase())) } val services = - serviceRepository.getServicesTypes(types = listOf(RemoteServiceType.JACKETT.value)) + serviceRepository.getServicesTypes( + types = + listOf(RemoteServiceType.JACKETT.value, RemoteServiceType.PROWLARR.value) + ) pluginLiveData.postEvent( PluginsAndServices( plugins = pluginsWithSelection, @@ -247,7 +252,8 @@ constructor( // todo add repo with suspend to access the db of complete remote servceis val enabledServices = serviceRepository.getEnabledServicesTypes( - types = listOf(RemoteServiceType.JACKETT.value) + types = + listOf(RemoteServiceType.JACKETT.value, RemoteServiceType.PROWLARR.value) ) if (enabledPlugins.isEmpty() && enabledServices.isEmpty()) { @@ -259,17 +265,26 @@ constructor( delay(100) supervisorScope { + // accumulate results from all plugin/service searches so they survive fragment + // recreation (saved to SavedStateHandle via setSearchResults) + val results = mutableListOf() + val pluginSearches = enabledPlugins.map { plugin -> launch { parser.completeSearch(plugin, query, category, page).collect { when (it) { is ParserResult.SingleResult -> { - parsingLiveData.value = ParserResult.SingleResult(it.value) + // accumulate and publish aggregated results + results.add(it.value) + parsingLiveData.value = ParserResult.Results(results) + setSearchResults(results) } is ParserResult.Results -> { - // here I have all the results at once - parsingLiveData.value = ParserResult.Results(it.values) + // add all and publish aggregated results + results.addAll(it.values) + parsingLiveData.value = ParserResult.Results(results) + setSearchResults(results) } is ParserResult.SearchStarted -> { @@ -289,26 +304,53 @@ constructor( } } } + val servicesSearches = enabledServices.map { service -> launch { Timber.d("Starting search for service ${service.name}") // todo: add categories support - jackettRepository.performSearch(service, query = query).collect { - when (it) { - is ParserResult.Results -> { - parsingLiveData.value = ParserResult.Results(it.values) + when (service.type) { + RemoteServiceType.JACKETT.value -> { + jackettRepository.performSearch(service, query = query).collect { + when (it) { + is ParserResult.Results -> { + results.addAll(it.values) + parsingLiveData.value = ParserResult.Results(results) + setSearchResults(results) + } + + else -> { + // not used yet + Timber.d( + "Received non-results parser result from service search: $it" + ) + } + } } + } - else -> { - // not used yet - Timber.d( - "Received non-results parser result from service search: $it" - ) + RemoteServiceType.PROWLARR.value -> { + prowlarrRepository.performSearch(service, query = query).collect { + when (it) { + is ParserResult.Results -> { + results.addAll(it.values) + parsingLiveData.value = ParserResult.Results(results) + setSearchResults(results) + } + + else -> { + // not used yet + Timber.d( + "Received non-results parser result from service search: $it" + ) + } + } } } } } } + pluginSearches.joinAll() servicesSearches.joinAll() } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Constants.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Constants.kt index 58e37e829..712fe9d31 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Constants.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Constants.kt @@ -23,10 +23,12 @@ const val PRIVATE_TOKEN: String = "private_token" const val REMOTE_TRAFFIC_ON: Int = 1 +const val HASH_PATTERN: String = "[a-zA-Z0-9]{32,}" const val MAGNET_PATTERN: String = "magnet:\\?xt=urn:btih:([a-zA-Z0-9]{32,})" const val TORRENT_PATTERN: String = "https?://[^\\s]{7,}\\.torrent" const val CONTAINER_PATTERN: String = "https?://[^\\s]{7,}\\.(rsdf|ccf3|ccf|dlc)" const val CONTAINER_EXTENSION_PATTERN: String = "[^\\s]+\\.(rsdf|ccf3|ccf|dlc)$" +const val IP_PATTERN: String = "^(((?!25?[6-9])[12]\\d|[1-9])?\\d\\.?\\b){4}" const val FEEDBACK_URL = "https://github.com/LivingWithHippos/unchained-android" const val GPLV3_URL = "https://www.gnu.org/licenses/gpl-3.0.en.html" diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Strings.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Strings.kt index 1c4c87247..03d087a0f 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Strings.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/Strings.kt @@ -3,6 +3,7 @@ package com.github.livingwithhippos.unchained.utilities import com.github.livingwithhippos.unchained.data.repository.PluginRepository val noWordDigitRegex = Regex("[^\\w\\d]+") +val ipRegex = Regex(IP_PATTERN) /** * Return a hashed string from the repository link, to be used as a folder name in the plugins @@ -34,7 +35,10 @@ fun getManualPluginFilename(author: String?, name: String): String { * Add the http scheme to the base url if it's not already there Optionally add https No checks are * performed on the url validity */ -fun addHttpScheme(baseUrl: String, useSecureHttp: Boolean = false): String { - return if (baseUrl.startsWith("http", ignoreCase = true)) baseUrl - else if (useSecureHttp) "https://$baseUrl" else "http://$baseUrl" +fun addHttpScheme(baseUrl: String, setIPHttp: Boolean = true): String { + if (baseUrl.startsWith("http", ignoreCase = true)) return baseUrl + if (!setIPHttp) return "http://$baseUrl" + if (ipRegex.containsMatchIn(baseUrl)) return "http://$baseUrl" + // we suppose it's a domain + return "https://$baseUrl" } diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/ViewExtension.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/ViewExtension.kt index e7ad50cb8..5ccf2b4b0 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/ViewExtension.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/utilities/extension/ViewExtension.kt @@ -197,6 +197,7 @@ fun setDrawableByServiceType(view: ImageView, type: Int) { RemoteServiceType.KODI.value -> view.setImageResource(R.drawable.icon_kodi) RemoteServiceType.VLC.value -> view.setImageResource(R.drawable.icon_vlc) RemoteServiceType.JACKETT.value -> view.setImageResource(R.drawable.icon_jackett) + RemoteServiceType.PROWLARR.value -> view.setImageResource(R.drawable.icon_prowlarr) else -> view.setImageResource(R.drawable.icon_play_outline) } } diff --git a/app/app/src/main/res/drawable/icon_prowlarr.xml b/app/app/src/main/res/drawable/icon_prowlarr.xml new file mode 100644 index 000000000..5d621fcd6 --- /dev/null +++ b/app/app/src/main/res/drawable/icon_prowlarr.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/app/src/main/res/layout/item_complete_service.xml b/app/app/src/main/res/layout/item_complete_service.xml index f519eba1a..2bd96bb05 100644 --- a/app/app/src/main/res/layout/item_complete_service.xml +++ b/app/app/src/main/res/layout/item_complete_service.xml @@ -57,7 +57,8 @@ -