Skip to content
Merged
8 changes: 4 additions & 4 deletions app/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,16 @@ 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 =
mapOf(
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 */
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> = emptyList(),
val categories: List<PCategory> = 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<PCategory> = emptyList(),
)

fun prowlarrToScrapedItems(context: Context, response: List<ProwlarrResponse>): List<ScrapedItem> {
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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -99,28 +101,49 @@ fun rssToScrapedItems(context: Context, rss: SearchRSS): Pair<List<ScrapedItem>,
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++
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int> = emptyList(),
categories: List<Int> = 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<ProwlarrResponse> = 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)
}
Loading