Fork of danielchc/amarr with Torznab action-name fix

Patched TorznabApi.kt to accept the hyphenated action names
(movie-search, tv-search) that Caps.kt already advertises and that
Radarr/Sonarr actually send, alongside the original non-hyphenated
variants (movie, tvsearch).

Upstream: https://github.com/danielchc/amarr
This commit is contained in:
alexandrump
2026-09-07 23:54:47 +02:00
commit 42c70c5d1b
45 changed files with 9248 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.jib)
application
}
println("Version is $version")
repositories {
mavenCentral()
mavenLocal()
}
dependencies {
implementation(libs.bundles.ktor.server)
implementation(libs.bundles.ktor.client)
implementation(libs.jamule)
implementation(libs.guava)
implementation(libs.logback)
implementation(libs.commons.text)
testImplementation(libs.bundles.kotest)
testImplementation(libs.mockk)
testImplementation(libs.ktor.server.test.host.jvm)
testImplementation(libs.ktor.client.mock)
testImplementation(libs.kotlin.test.junit)
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
application {
mainClass.set("amarr.AppKt")
}
tasks.named<Test>("test") {
useJUnitPlatform()
}
jib {
from {
image = "eclipse-temurin:17-jre-ubi9-minimal"
platforms {
platform {
architecture = "amd64"
os = "linux"
}
platform {
architecture = "arm64"
os = "linux"
}
}
}
to {
image = "danielchc/amarr"
tags = setOf(version.toString())
auth {
username = System.getenv("DOCKER_USERNAME")
password = System.getenv("DOCKER_PASSWORD")
}
}
}
+115
View File
@@ -0,0 +1,115 @@
package amarr
import amarr.amule.debugApi
import amarr.indexer.cache.CacheStore
import amarr.category.FileCategoryStore
import amarr.indexer.filters.MediaFilter
import amarr.torrent.torrentApi
import amarr.indexer.implementations.amule.AmuleIndexer
import amarr.indexer.torznab.torznabApi
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.callloging.*
import io.ktor.server.plugins.contentnegotiation.*
import jamule.AmuleClient
import kotlinx.serialization.json.Json
import kotlinx.coroutines.*
import org.jetbrains.annotations.VisibleForTesting
import org.slf4j.Logger
import org.slf4j.event.Level
private const val DEFAULT_AMARR_EXTENSIONS =
"webm, m4v, 3gp, nsv, ty, strm, rm, rmvb, m3u, ifo, mov, qt, divx, xvid, bivx, nrg, pva, wmv, asf, asx, ogm, ogv, m2v, avi, bin, dat, dvr-ms, mpg, mpeg, mp4, avc, vp3, svq3, nuv, viv, dv, fli, flv, wpl, img, iso, vob, mkv, mk3d, ts, wtv, m2ts, 7z, bz2, gz, r00, rar, tar.bz2, tar.gz, tar, tb2, tbz2, tgz, zip, zipx"
private val AMULE_PORT = System.getenv("AMULE_PORT").apply {
if (this == null) throw Exception("AMULE_PORT is not set")
}
private val AMULE_HOST = System.getenv("AMULE_HOST").apply {
if (this == null) throw Exception("AMULE_HOST is not set")
}
private val AMULE_PASSWORD = System.getenv("AMULE_PASSWORD").apply {
if (this == null) throw Exception("AMULE_PASSWORD is not set")
}
private val AMULE_FINISHED_PATH = System.getenv("AMULE_FINISHED_PATH").let { it ?: "/finished" }
private val AMARR_CONFIG_PATH = System.getenv("AMARR_CONFIG_PATH").let { it ?: "/config" }
private val AMARR_LOG_LEVEL = System.getenv("AMARR_LOG_LEVEL").let { it ?: "WARN" }
private val AMARR_CACHE_TTL_MS: Long = System.getenv("AMARR_CACHE_TTL_MS")?.toLongOrNull() ?: 1800_000
private val AMARR_EXTENSION_FILTER: List<String> = (System.getenv("AMARR_EXTENSION_FILTER") ?: DEFAULT_AMARR_EXTENSIONS)
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
private fun setLogLevel(logger: Logger) {
val logBackLogger = logger as ch.qos.logback.classic.Logger
when (AMARR_LOG_LEVEL) {
"DEBUG" -> logBackLogger.level = ch.qos.logback.classic.Level.DEBUG
"INFO" -> logBackLogger.level = ch.qos.logback.classic.Level.INFO
"WARN" -> logBackLogger.level = ch.qos.logback.classic.Level.WARN
"ERROR" -> logBackLogger.level = ch.qos.logback.classic.Level.ERROR
else -> throw Exception("Unknown log level: $AMARR_LOG_LEVEL")
}
}
fun main() {
embeddedServer(
Netty,
port = 4713
) {
app()
}.start(wait = true)
}
@VisibleForTesting
internal fun Application.app() {
setLogLevel(log)
val cacheStore = CacheStore(AMARR_CACHE_TTL_MS)
val amuleClient = AmuleClient(AMULE_HOST, AMULE_PORT.toInt(), AMULE_PASSWORD, logger = log)
val amuleIndexer = AmuleIndexer(amuleClient, log, cacheStore)
val categoryStore = FileCategoryStore(AMARR_CONFIG_PATH)
val mediaFilter = MediaFilter(AMARR_EXTENSION_FILTER)
install(CallLogging) {
level = Level.DEBUG
}
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
prettyPrint = true
encodeDefaults = true
})
}
debugApi(amuleClient)
torznabApi(amuleIndexer, mediaFilter)
torrentApi(amuleClient, categoryStore, AMULE_FINISHED_PATH)
startPeriodicJob(everyMillis = AMARR_CACHE_TTL_MS) {
log.debug("Cleaning cached results...")
cacheStore.cleanup()
}
}
fun Application.startPeriodicJob(everyMillis: Long, task: suspend () -> Unit) {
environment.monitor.subscribe(ApplicationStarted) {
launch {
while (isActive) {
try {
task()
} catch (e: Exception) {
log.error("Error in periodic task", e)
}
delay(everyMillis)
}
}
}
}
+98
View File
@@ -0,0 +1,98 @@
package amarr
import com.google.common.io.BaseEncoding.base32
import io.ktor.http.*
data class MagnetLink(
private val hash: ByteArray,
val name: String,
val size: Long,
val trackers: List<String>,
) {
fun toEd2kLink(): String {
return "ed2k://|file|${name.encodeURLParameter()}|$size|${amuleHexHash()}|/"
}
@OptIn(ExperimentalStdlibApi::class)
fun amuleHexHash(): String {
// unpad the hash to ensure a size of 128 bits, then encode it as hex
return hash.copyOf(16).toHexString()
}
fun isAmarr(): Boolean {
return trackers.contains(AMARR_TRACKER)
}
override fun toString(): String {
// pad the hash to ensure a size of 160 bits
val hash = hash.copyOf(20)
val base32Hash = base32().encode(hash)
return "magnet:" +
"?xt=urn:btih:$base32Hash" +
"&dn=${name.encodeURLParameter()}" +
"&xl=$size" +
"&tr=${trackers.joinToString("&tr=") { it.encodeURLParameter() }}"
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MagnetLink
if (!hash.contentEquals(other.hash)) return false
if (name != other.name) return false
if (size != other.size) return false
if (trackers != other.trackers) return false
return true
}
override fun hashCode(): Int {
var result = hash.contentHashCode()
result = 31 * result + name.hashCode()
result = 31 * result + size.hashCode()
result = 31 * result + trackers.hashCode()
return result
}
companion object {
fun forAmarr(hash: ByteArray, name: String, size: Long) = MagnetLink(
hash = hash,
name = name,
size = size,
trackers = listOf(AMARR_TRACKER)
)
fun fromString(magnet: String): MagnetLink = magnet
.substringAfter("magnet:?")
.split("&")
.filter { it.matches(Regex(".+=.+")) }
.map { val els = it.split("="); els[0] to els[1] }
.let { params ->
val hash = base32().decode(params.first { it.first == "xt" }.second.substringAfter("urn:btih:"))
MagnetLink(
hash = hash,
name = params.first { it.first == "dn" }.second.decodeURLPart(),
size = params.first { it.first == "xl" }.second.toLong(),
trackers = params.filter { it.first == "tr" }.map { it.second.decodeURLPart() }
)
}
@OptIn(ExperimentalStdlibApi::class)
fun fromEd2k(ed2k: String): MagnetLink = ed2k
.substringAfter("ed2k://|file|")
.substringBefore("|/")
.split("|")
.let { els ->
MagnetLink(
hash = els[2].hexToByteArray(),
name = els[0].decodeURLPart(),
size = els[1].toLong(),
trackers = listOf(AMARR_TRACKER)
)
}
const val AMARR_TRACKER = "http://amarr-reserved"
}
}
+125
View File
@@ -0,0 +1,125 @@
package amarr.amule
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import jamule.AmuleClient
import jamule.response.StatsResponse
import kotlinx.serialization.Serializable
fun Application.debugApi(client: AmuleClient) {
routing {
get("/status") {
call.respond(client.getStats().getOrThrow().let { StatusResponse.fromStatsResponse(it) })
}
}
}
@Serializable
data class StatusResponse(
val bannedCount: Long = 0,
val buddyIp: String? = null,
val buddyPort: Short? = null,
val buddyStatus: String? = null,
val connectionStatus: ConnectionStatus? = ConnectionStatus(),
val downloadOverhead: Long = 0,
val downloadSpeed: Long = 0,
val downloadSpeedLimit: Long = 0,
val ed2kFiles: Long = 0,
val ed2kUsers: Long = 0,
val kadFiles: Long = 0,
val kadFirewalledUdp: Boolean? = null,
val kadIndexedKeywords: Long? = null,
val kadIndexedLoad: Long? = null,
val kadIndexedNotes: Long? = null,
val kadIndexedSources: Long? = null,
val kadIpAddress: String? = null,
val kadIsRunningInLanMode: Boolean? = null,
val kadNodes: Long = 0,
val kadUsers: Long = 0,
val loggerMessage: List<String> = emptyList(),
val sharedFileCount: Long = 0,
val totalReceivedBytes: Long = 0,
val totalSentBytes: Long = 0,
val totalSourceCount: Long = 0,
val uploadOverhead: Long = 0,
val uploadQueueLength: Long = 0,
val uploadSpeed: Long = 0,
val uploadSpeedLimit: Long = 0
) {
companion object {
fun fromStatsResponse(statsResponse: StatsResponse) =
StatusResponse(
bannedCount = statsResponse.bannedCount,
buddyIp = statsResponse.buddyIp,
buddyPort = statsResponse.buddyPort?.toShort(),
buddyStatus = statsResponse.buddyStatus?.name,
connectionStatus = ConnectionStatus(
clientId = statsResponse.connectionState?.clientId,
ed2kConnected = statsResponse.connectionState?.ed2kConnected,
ed2kConnecting = statsResponse.connectionState?.ed2kConnecting,
ed2kId = statsResponse.connectionState?.ed2kId,
kadConnected = statsResponse.connectionState?.kadConnected,
kadFirewalled = statsResponse.connectionState?.kadFirewalled,
kadId = statsResponse.connectionState?.kadId,
kadRunning = statsResponse.connectionState?.kadRunning,
serverDescription = statsResponse.connectionState?.serverDescription,
serverFailed = statsResponse.connectionState?.serverFailed,
serverFiles = statsResponse.connectionState?.serverFiles,
serverIpv4 = statsResponse.connectionState?.serverIpv4?.address,
serverPing = statsResponse.connectionState?.serverPing,
serverPrio = statsResponse.connectionState?.serverPrio,
serverStatic = statsResponse.connectionState?.serverStatic,
serverUsers = statsResponse.connectionState?.serverUsers,
serverUsersMax = statsResponse.connectionState?.serverUsersMax,
serverVersion = statsResponse.connectionState?.serverVersion
),
downloadOverhead = statsResponse.downloadOverhead,
downloadSpeed = statsResponse.downloadSpeed,
downloadSpeedLimit = statsResponse.downloadSpeedLimit,
ed2kFiles = statsResponse.ed2kFiles,
ed2kUsers = statsResponse.ed2kUsers,
kadFiles = statsResponse.kadFiles,
kadFirewalledUdp = statsResponse.kadFirewalledUdp,
kadIndexedKeywords = statsResponse.kadIndexedKeywords,
kadIndexedLoad = statsResponse.kadIndexedLoad,
kadIndexedNotes = statsResponse.kadIndexedNotes,
kadIndexedSources = statsResponse.kadIndexedSources,
kadIpAddress = statsResponse.kadIpAddress,
kadIsRunningInLanMode = statsResponse.kadIsRunningInLanMode,
kadNodes = statsResponse.kadNodes,
kadUsers = statsResponse.kadUsers,
loggerMessage = statsResponse.loggerMessage,
sharedFileCount = statsResponse.sharedFileCount,
totalReceivedBytes = statsResponse.totalReceivedBytes,
totalSentBytes = statsResponse.totalSentBytes,
totalSourceCount = statsResponse.totalSourceCount,
uploadOverhead = statsResponse.uploadOverhead,
uploadQueueLength = statsResponse.uploadQueueLength,
uploadSpeed = statsResponse.uploadSpeed,
uploadSpeedLimit = statsResponse.uploadSpeedLimit
)
}
}
@Serializable
data class ConnectionStatus(
val clientId: Int? = null,
val ed2kConnected: Boolean? = null,
val ed2kConnecting: Boolean? = null,
val ed2kId: Int? = null,
val kadConnected: Boolean? = null,
val kadFirewalled: Boolean? = null,
val kadId: Int? = null,
val kadRunning: Boolean? = null,
val serverDescription: String? = null,
val serverFailed: Int? = null,
val serverFiles: Int? = null,
val serverIpv4: String? = null,
val serverPing: Int? = null,
val serverPrio: Int? = null,
val serverStatic: Boolean? = null,
val serverUsers: Int? = null,
val serverUsersMax: Int? = null,
val serverVersion: String? = null
)
@@ -0,0 +1,11 @@
package amarr.category
import amarr.torrent.model.Category
interface CategoryStore {
fun store(category: String, hash: String)
fun getCategory(hash: String): String?
fun delete(hash: String)
fun addCategory(category: Category)
fun getCategories(): Set<Category>
}
@@ -0,0 +1,90 @@
package amarr.category
import amarr.torrent.model.Category
import java.io.File
/**
* Stores the relation category - file hash in a file in the amule config directory.
* The access to this file is synchronized.
*/
class FileCategoryStore(storePath: String) : CategoryStore {
private val hashesCache: MutableMap<String, String> = mutableMapOf()
private var categoriesCache: MutableSet<Category>? = null
private var categoriesFilePath = File(storePath, CATEGORIES_FILE).absolutePath
private var hashesFilePath = File(storePath, HASHES_FILE).absolutePath
override fun store(category: String, hash: String) {
synchronized(HASHES_FILE) {
if (category.contains('\t') || hash.contains('\t'))
throw IllegalArgumentException("Category or hash contains tab character")
val file = File(hashesFilePath)
if (!file.exists()) {
file.parentFile.mkdirs()
file.createNewFile()
}
file.appendText("$hash\t$category\n")
hashesCache[hash] = category
}
}
override fun getCategory(hash: String): String? {
synchronized(HASHES_FILE) {
if (hashesCache.containsKey(hash))
return hashesCache[hash]
val file = File(hashesFilePath)
if (!file.exists())
return null
val line = file.readLines().find { it.split('\t')[0] == hash } ?: return null
val category = line.split('\t')[1]
hashesCache[hash] = category
return category
}
}
override fun delete(hash: String) {
synchronized(HASHES_FILE) {
val file = File(hashesFilePath)
if (!file.exists())
return
val lines = file.readLines()
val line = lines.find { it.split('\t')[0] == hash } ?: return
file.writeText(lines.filterNot { it == line }.joinToString("\n"))
hashesCache.remove(hash)
}
}
override fun addCategory(category: Category) {
synchronized(CATEGORIES_FILE) {
if (categoriesCache != null)
categoriesCache!!.add(category)
val file = File(categoriesFilePath)
if (!file.exists()) {
file.parentFile.mkdirs()
file.createNewFile()
}
file.appendText("${category.name}\t${category.savePath}\n")
}
}
override fun getCategories(): Set<Category> {
synchronized(CATEGORIES_FILE) {
if (categoriesCache != null)
return categoriesCache!!
val file = File(categoriesFilePath)
if (!file.exists())
return emptySet()
val categories = file.readLines().map { line ->
val split = line.split('\t')
Category(split[0], split[1])
}
categoriesCache = categories.toMutableSet()
return categoriesCache!!
}
}
companion object {
private const val CATEGORIES_FILE = "categories.tsv"
private const val HASHES_FILE = "hashes.tsv"
}
}
@@ -0,0 +1,20 @@
package amarr.indexer
import amarr.indexer.search.SearchQuery
import amarr.indexer.caps.Caps
import amarr.indexer.filters.MediaFilter
import amarr.indexer.torznab.TorznabFeed
interface Indexer {
/**
* Given a paginated query, returns a [TorznabFeed] with the results.
*/
suspend fun search(query: SearchQuery, mediaFilter: MediaFilter, offset: Int, limit: Int, cat: List<Int>): TorznabFeed
/**
* Returns the capabilities of this indexer.
*/
suspend fun capabilities(): Caps
}
+56
View File
@@ -0,0 +1,56 @@
package amarr.indexer.cache
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
class CacheStore(
private val defaultTtlMillis: Long = 1800_000, // default TTL: 30 minutes
) {
private data class CacheEntry<T>(val value: T, val expiresAt: Instant?)
private val cache = ConcurrentHashMap<String, CacheEntry<Any>>()
/**
* Put a value into the cache
*/
fun put(key: String, value: Any) {
val expiresAt = Instant.now().plusMillis(defaultTtlMillis)
cache[key] = CacheEntry(value, expiresAt)
}
/**
* Get a value from the cache, or null if not found or expired.
*/
@Suppress("UNCHECKED_CAST")
fun <T> get(key: String): T? {
val entry = cache[key] ?: return null
if (entry.expiresAt != null && entry.expiresAt.isBefore(Instant.now())) {
cache.remove(key)
return null
}
return entry.value as? T
}
/**
* Remove a value from the cache.
*/
fun remove(key: String) {
cache.remove(key)
}
/**
* Clean up expired entries. You can run this periodically.
*/
fun cleanup() {
val now = Instant.now()
cache.entries.removeIf { it.value.expiresAt?.isBefore(now) == true }
}
/**
* Clear all entries.
*/
fun clear() {
cache.clear()
}
}
@@ -0,0 +1,91 @@
package amarr.indexer.caps
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName("caps")
data class Caps(
val server: Server = Server(),
val limits: Limits = Limits(),
val searching: Searching = Searching(),
val categories: Categories = Categories()
) {
@Serializable
@SerialName("server")
data class Server(val version: String = "1.0", val title: String = "Amarr")
@Serializable
@SerialName("limits")
data class Limits(val max: Int = 10000, val default: Int = 10000)
@Serializable
@SerialName("searching")
data class Searching(
val search: Search = Search(),
val tvSearch: TvSearch = TvSearch(),
val movieSearch: MovieSearch = MovieSearch(),
val audioSearch: AudioSearch = AudioSearch(),
val bookSearch: BookSearch = BookSearch()
) {
@Serializable
@SerialName("search")
data class Search(
val available: String = "yes",
val supportedParams: String = "q,language",
val searchEngine: String = "raw",
)
@Serializable
@SerialName("tv-search")
data class TvSearch(
val available: String = "yes",
val supportedParams: String = "q,season,ep,language",
val searchEngine: String = "raw",
)
@Serializable
@SerialName("movie-search")
data class MovieSearch(
val available: String = "yes",
val supportedParams: String = "q,language",
val searchEngine: String = "raw",
)
@Serializable
@SerialName("audio-search")
data class AudioSearch(
val available: String = "no",
val supportedParams: String = "q",
val searchEngine: String = "raw",
)
@Serializable
@SerialName("book-search")
data class BookSearch(
val available: String = "no",
val supportedParams: String = "q",
val searchEngine: String = "raw",
)
}
@Serializable
@SerialName("categories")
class Categories(
val category: List<Category> = listOf(
Category(
id = 1,
name = "All",
)
)
) {
@Serializable
@SerialName("category")
data class Category(
val id: Int,
val name: String,
)
}
}
@@ -0,0 +1,3 @@
package amarr.indexer.exceptions
class ThrottledException : Exception()
@@ -0,0 +1,3 @@
package amarr.indexer.exceptions
class UnauthorizedException : Exception()
@@ -0,0 +1,7 @@
package amarr.indexer.filters
class MediaFilter(private val mediaExtensions: List<String>) {
fun filter(fileName: String): Boolean {
return mediaExtensions.any { fileName.endsWith(".${it}") }
}
}
@@ -0,0 +1,121 @@
package amarr.indexer.implementations.amule
import amarr.MagnetLink
import amarr.indexer.Indexer
import amarr.indexer.cache.CacheStore
import amarr.indexer.caps.Caps
import amarr.indexer.filters.MediaFilter
import amarr.indexer.search.SearchFormat
import amarr.indexer.search.SearchQuery
import amarr.indexer.search.SearchType
import amarr.indexer.torznab.TorznabFeed
import io.ktor.util.logging.Logger
import jamule.AmuleClient
import jamule.response.SearchResultsResponse
import kotlin.system.measureTimeMillis
class AmuleIndexer(private val amuleClient: AmuleClient, private val log: Logger, private val cacheStore: CacheStore) :
Indexer {
override suspend fun search(
query: SearchQuery,
mediaFilter: MediaFilter,
offset: Int,
limit: Int,
cat: List<Int>
): TorznabFeed {
// https://wiki.amule.org/wiki/Search_regexp
if (query.q.isBlank()) {
log.debug("Empty query, returning empty response")
return EMPTY_QUERY_RESPONSE
}
val regexpQuery: String = when (query.searchType) {
SearchType.TV -> "%s AND (%s)".format(
query.getCleanedQuery(),
(SearchFormat.Companion.epSearchFormat.map { k -> k.format(query.season, query.episode) }).joinToString(
" OR "
)
)
else -> query.getCleanedQuery()
}
log.info("Starting search for query: '{}', offset: {}, limit: {}", regexpQuery, offset, limit)
val (items, duration) = measureTimedValue {
cacheStore.get<List<SearchResultsResponse.SearchFile>>(regexpQuery)?.also {
log.info("Cache hit for query: $regexpQuery")
} ?: run {
log.info("Cache miss for query: $regexpQuery — fetching")
val fetched = amuleClient.searchSync(regexpQuery).getOrThrow().files.filter { result ->
mediaFilter.filter(result.fileName)
}
cacheStore.put(regexpQuery, fetched)
fetched
}
}
log.info("End search for query: '{}': {} results in {} ms", regexpQuery, items.size, duration)
return buildFeed(items, offset, limit)
}
private fun buildFeed(items: List<SearchResultsResponse.SearchFile>, offset: Int, limit: Int) = TorznabFeed(
channel = TorznabFeed.Channel(
response = TorznabFeed.Channel.Response(
offset = offset,
total = items.size
),
item = items
.drop(offset)
.take(limit)
.map { result ->
TorznabFeed.Channel.Item(
title = result.fileName,
enclosure = TorznabFeed.Channel.Item.Enclosure(
url = MagnetLink.Companion.forAmarr(result.hash, result.fileName, result.sizeFull)
.toString(),
length = result.sizeFull
),
attributes = listOf(
TorznabFeed.Channel.Item.TorznabAttribute("category", "1"),
TorznabFeed.Channel.Item.TorznabAttribute("seeders", result.completeSourceCount.toString()),
TorznabFeed.Channel.Item.TorznabAttribute("peers", result.sourceCount.toString()),
TorznabFeed.Channel.Item.TorznabAttribute("size", result.sizeFull.toString())
)
)
}
)
)
companion object {
private val EMPTY_QUERY_RESPONSE = TorznabFeed(
channel = TorznabFeed.Channel(
response = TorznabFeed.Channel.Response(offset = 0, total = 1),
item = listOf(
TorznabFeed.Channel.Item(
title = "No query provided",
enclosure = TorznabFeed.Channel.Item.Enclosure("http://mock.url", 0),
attributes = listOf(
TorznabFeed.Channel.Item.TorznabAttribute("category", "1"),
TorznabFeed.Channel.Item.TorznabAttribute("size", "0")
)
)
)
)
)
}
override suspend fun capabilities(): Caps = Caps()
inline fun <T> measureTimedValue(block: () -> T): Pair<T, Long> {
val start = System.currentTimeMillis()
val result = block()
val duration = System.currentTimeMillis() - start
return result to duration
}
}
@@ -0,0 +1,7 @@
package amarr.indexer.search
class SearchFormat {
companion object {
val epSearchFormat: List<String> = listOf("%dx%02d", "S%02dE%02d", "%d%02d")
}
}
@@ -0,0 +1,12 @@
package amarr.indexer.search
import java.text.Normalizer
class SearchQuery(val q: String, val searchType: SearchType, val season: Int? = null, val episode: Int? = null, ){
fun getCleanedQuery(): String{
val withoutAccents = Normalizer.normalize(q, Normalizer.Form.NFD)
.replace(Regex("\\p{InCombiningDiacriticalMarks}"), "")
// Step 2: Remove non-alphanumeric and non-whitespace characters
return withoutAccents.replace(Regex("[^\\w\\s]"), "")
}
}
@@ -0,0 +1,7 @@
package amarr.indexer.search
enum class SearchType {
TV,
Movie,
Search
}
@@ -0,0 +1,78 @@
package amarr.indexer.torznab
import amarr.indexer.search.SearchQuery
import amarr.indexer.search.SearchType
import amarr.indexer.implementations.amule.AmuleIndexer
import amarr.indexer.Indexer
import amarr.indexer.exceptions.ThrottledException
import amarr.indexer.exceptions.UnauthorizedException
import amarr.indexer.filters.MediaFilter
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.encodeToString
import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.serialization.XML
fun Application.torznabApi(amuleIndexer: AmuleIndexer, mediaFilter: MediaFilter) {
routing {
get("/indexer/amule/api") {
call.handleRequests(amuleIndexer, mediaFilter)
}
}
}
private suspend fun ApplicationCall.handleRequests(indexer: Indexer, mediaFilter: MediaFilter) {
application.log.debug("Handling Torznab request")
val xmlFormat = XML {
xmlDeclMode = XmlDeclMode.Charset
xmlVersion = XmlVersion.XML10
} // This API uses XML instead of JSON
request.queryParameters["t"]?.let {
when (it) {
"caps" -> {
application.log.debug("Handling caps request")
respondText(xmlFormat.encodeToString(indexer.capabilities()), contentType = ContentType.Application.Xml)
}
"tvsearch", "tv-search" -> performSearch(indexer, xmlFormat, mediaFilter, SearchType.TV)
"movie", "movie-search" -> performSearch(indexer, xmlFormat, mediaFilter, SearchType.Movie)
"search" -> performSearch(indexer, xmlFormat, mediaFilter, SearchType.Search)
else -> throw IllegalArgumentException("Unknown action: $it")
}
} ?: throw IllegalArgumentException("Missing action")
}
private suspend fun ApplicationCall.performSearch(
indexer: Indexer,
xmlFormat: XML,
mediaFilter: MediaFilter,
searchType: SearchType
) {
val query = request.queryParameters["q"].orEmpty()
val ep = request.queryParameters["season"]?.toIntOrNull() ?: 0
val season = request.queryParameters["ep"]?.toIntOrNull() ?: 0
val offset = request.queryParameters["offset"]?.toIntOrNull() ?: 0
val limit = request.queryParameters["limit"]?.toIntOrNull() ?: 100
val cat = request.queryParameters["cat"]?.split(",")?.map { cat -> cat.toInt() } ?: emptyList()
application.log.debug("Handling search request: {}, {}, {}, {}", query, offset, limit, cat)
try {
val searchQuery: SearchQuery = when (searchType) {
SearchType.TV -> SearchQuery(query, searchType, ep, season)
else -> SearchQuery(query, searchType)
}
respondText(
xmlFormat.encodeToString(indexer.search(searchQuery, mediaFilter, offset, limit, cat)),
contentType = ContentType.Application.Xml
)
} catch (e: ThrottledException) {
application.log.warn("Throttled, returning 403")
respondText("You are being throttled. Retry in a few minutes.", status = HttpStatusCode.Forbidden)
} catch (e: UnauthorizedException) {
application.log.warn("Unauthorized, returning 401")
respondText("Unauthorized, check your credentials.", status = HttpStatusCode.Unauthorized)
}
}
@@ -0,0 +1,67 @@
package amarr.indexer.torznab
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import nl.adaptivity.xmlutil.ExperimentalXmlUtilApi
import nl.adaptivity.xmlutil.serialization.XmlElement
import nl.adaptivity.xmlutil.serialization.XmlNamespaceDeclSpec
import nl.adaptivity.xmlutil.serialization.XmlSerialName
@OptIn(ExperimentalXmlUtilApi::class)
@Serializable
@XmlNamespaceDeclSpec("${TorznabFeed.TORZNAB_PREFIX}=${TorznabFeed.TORZNAB_NAMESPACE}")
@SerialName("rss")
data class TorznabFeed(val version: String = "2.0", val channel: Channel) {
@Serializable
@SerialName("channel")
data class Channel(
@XmlElement
val title: String = "Amarr",
@XmlElement
val description: String = "Amarr 1.0",
val response: Response,
val item: List<Item>
) {
@Serializable
@XmlSerialName("response", TORZNAB_NAMESPACE, TORZNAB_PREFIX)
data class Response(
val offset: Int,
val total: Int,
)
@Serializable
@SerialName("item")
data class Item(
@XmlElement
val title: String,
@XmlElement
val pubDate: String = "Sat, 14 Mar 2015 12:42:19 -0400",
val enclosure: Enclosure,
val attributes: List<TorznabAttribute>
) {
@Serializable
@SerialName("enclosure")
data class Enclosure(
val url: String,
val length: Long,
val type: String = "application/x-bittorrent"
)
@Serializable
@XmlSerialName("attr", TORZNAB_NAMESPACE, TORZNAB_PREFIX)
data class TorznabAttribute(
val name: String,
val value: String,
)
}
}
companion object {
const val TORZNAB_NAMESPACE = "http://torznab.com/schemas/2015/feed"
const val TORZNAB_PREFIX = "torznab"
}
}
@@ -0,0 +1,83 @@
package amarr.torrent
import amarr.category.CategoryStore
import amarr.torrent.model.Category
import amarr.torrent.model.Preferences
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import jamule.AmuleClient
fun Application.torrentApi(amuleClient: AmuleClient, categoryStore: CategoryStore, finishedPath: String) {
val service = TorrentService(amuleClient, categoryStore, finishedPath, log)
routing {
get("/api/v2/app/webapiVersion") {
call.respondText("2.8.19") // Emulating qBittorrent API version 2.8.19
}
post("/api/v2/auth/login") {
val params = call.receiveParameters()
val username = params["username"]
val password = params["password"]
// TODO: Implement some kind of authentication
call.respondText("Ok.")
}
get("/api/v2/app/preferences") {
call.respond(Preferences(save_path = finishedPath))
}
post("/api/v2/torrents/add") {
val params = call.receiveParameters()
val urls = params["urls"]?.split("\n")?.filterNot { it.isBlank() }
val category = params["category"]
val paused = params["paused"]
call.application.log.debug(
"Received add torrent request with urls: {}, category: {}, paused: {}",
urls,
category,
paused
)
service.addTorrent(urls, category, paused)
call.respondText("Ok.")
}
post("/api/v2/torrents/createCategory") {
val params = call.receiveParameters()
val category = Category(params["category"]!!, params["savePath"] ?: "")
call.application.log.debug("Received create category request with category: {}", category)
service.addCategory(category)
call.respondText("Ok.")
}
get("/api/v2/torrents/categories") {
call.respond(service.getCategories())
}
get("/api/v2/torrents/info") {
val category = call.request.queryParameters["category"]
call.respond(service.getTorrentInfo(category))
}
post("/api/v2/torrents/delete") {
val params = call.receiveParameters()
val hashes = params["hashes"]!!.split("|")
val deleteFiles = params["deleteFiles"]
call.application.log.debug(
"Received delete torrent request with hashes: {}, deleteFiles: {}",
hashes,
deleteFiles
)
if (hashes.size == 1 && hashes[0] == "all")
service.deleteAllTorrents(deleteFiles)
else service.deleteTorrent(hashes, deleteFiles)
call.respondText("Ok.")
}
get("/api/v2/torrents/files") {
val hash = call.request.queryParameters["hash"]!!
call.application.log.debug("Received get files request with hash: {}", hash)
val response = listOf(service.getFile(hash))
call.respond(response)
}
get("/api/v2/torrents/properties") {
val hash = call.request.queryParameters["hash"]!!
call.application.log.debug("Received get properties request with hash: {}", hash)
val response = service.getTorrentProperties(hash)
call.respond(response)
}
}
}
@@ -0,0 +1,171 @@
package amarr.torrent
import amarr.MagnetLink
import amarr.category.CategoryStore
import amarr.torrent.model.*
import io.ktor.server.plugins.*
import io.ktor.util.logging.*
import jamule.AmuleClient
import jamule.model.AmuleTransferringFile
import jamule.model.DownloadCommand
import jamule.model.FileStatus
import kotlin.io.path.Path
class TorrentService(
private val amuleClient: AmuleClient,
private val categoryStore: CategoryStore,
private val finishedPath: String,
private val log: Logger
) {
fun getTorrentInfo(category: String?): List<TorrentInfo> {
val downloadingFiles = amuleClient
.getDownloadQueue()
.getOrThrow()
val sharedFiles = amuleClient.getSharedFiles().getOrThrow()
val downloadingFilesHashSet = downloadingFiles.map { it.fileHashHexString }.toHashSet()
val allFiles = (sharedFiles // Downloading files also appear in shared files
.filterNot { downloadingFilesHashSet.contains(it.fileHashHexString) } + downloadingFiles)
.filter { category == null || categoryStore.getCategory(it.fileHashHexString!!) == category }
return allFiles
.map { dl ->
if (dl is AmuleTransferringFile)
TorrentInfo(
hash = dl.fileHashHexString!!,
name = dl.fileName!!,
size = dl.sizeFull!!,
total_size = dl.sizeFull!!,
save_path = finishedPath,
downloaded = dl.sizeDone!!,
progress = dl.sizeDone!!.toDouble() / dl.sizeFull!!.toDouble(),
priority = dl.downPrio.toInt(),
state = if (dl.sourceXferCount > 0) TorrentState.downloading
else when (dl.fileStatus) {
FileStatus.READY -> TorrentState.metaDL
FileStatus.ERROR -> TorrentState.error
FileStatus.COMPLETING -> TorrentState.checkingDL
FileStatus.COMPLETE -> TorrentState.uploading
FileStatus.PAUSED -> TorrentState.pausedDL
FileStatus.ALLOCATING -> TorrentState.allocating
FileStatus.INSUFFICIENT -> TorrentState.error
.also { log.error("Insufficient disk space") }
else -> TorrentState.unknown
},
category = category,
dlspeed = dl.speed!!,
num_seeds = dl.sourceXferCount.toInt(),
eta = computeEta(dl.speed!!, dl.sizeFull!!, dl.sizeDone!!),
)
else
// File is already fully downloaded
TorrentInfo(
hash = dl.fileHashHexString!!,
name = dl.fileName!!,
size = dl.sizeFull!!,
total_size = dl.sizeFull!!,
save_path = finishedPath,
dlspeed = 0,
downloaded = dl.sizeFull!!,
progress = 1.0,
priority = 0,
state = TorrentState.uploading,
category = category,
eta = 0,
num_seeds = 0, // Irrelevant
)
}
}
private fun computeEta(speed: Long, sizeFull: Long, sizeDone: Long): Int {
val remainingBytes = sizeFull - sizeDone
return if (speed == 0L) 8640000 else Math.min((remainingBytes / speed).toInt(), 8640000)
}
fun getCategories(): Map<String, Category> = categoryStore
.getCategories()
.associateBy { it.name }
fun addCategory(category: Category) = categoryStore.addCategory(category)
fun addTorrent(urls: List<String>?, category: String?, paused: String?) {
if (urls == null) {
log.error("No urls provided")
throw nonAmarrLink("No urls provided")
}
urls.forEach { url ->
val magnetLink = try {
MagnetLink.fromString(url)
} catch (e: Exception) {
throw nonAmarrLink(url)
}
if (!magnetLink.isAmarr()) {
throw nonAmarrLink(url)
}
amuleClient.downloadEd2kLink(magnetLink.toEd2kLink())
if (category != null) {
categoryStore.store(category, magnetLink.amuleHexHash())
}
}
}
@OptIn(ExperimentalStdlibApi::class)
fun deleteTorrent(hashes: List<String>, deleteFiles: String?) {
val downloadingFiles = amuleClient
.getDownloadQueue()
.getOrThrow()
hashes.forEach { hash ->
if (downloadingFiles.any { it.fileHashHexString == hash }) {
amuleClient.sendDownloadCommand(hash.hexToByteArray(), DownloadCommand.DELETE)
} else if (deleteFiles == "true") {
deleteSharedFileByHash(hash)
} else {
log.error("File with hash $hash not found in downloading files")
}
categoryStore.delete(hash)
}
}
@OptIn(ExperimentalStdlibApi::class)
fun deleteAllTorrents(deleteFiles: String?) = amuleClient.getSharedFiles().getOrThrow().forEach { file ->
amuleClient.sendDownloadCommand(file.fileHashHexString!!.hexToByteArray(), DownloadCommand.DELETE)
categoryStore.delete(file.fileHashHexString!!)
}
fun getFile(hash: String) = getTorrentInfo(null)
.first { it.hash == hash }
.let {
TorrentFile(
name = it.name,
)
}
fun getTorrentProperties(hash: String): TorrentProperties = getTorrentInfo(null)
.first { it.hash == hash }
.let {
TorrentProperties(
hash = it.hash,
save_path = it.save_path,
seeding_time = 0,
)
}
private fun deleteSharedFileByHash(hash: String) = amuleClient
.getSharedFiles()
.getOrThrow()
.firstOrNull { it.fileHashHexString == hash }
?.filePath
?.let { Path(it).toFile().delete() }
?: log.error("File with hash $hash not found in shared files")
private fun nonAmarrLink(url: String): Exception {
log.error(
"The provided link does not appear to be an Amarr link: {}. " +
"Have you configured Radarr/Sonarr's download client priority correctly? See README.md", url
)
return NotFoundException("The provided link does not appear to be an Amarr link: $url")
}
}
@@ -0,0 +1,9 @@
package amarr.torrent.model
import kotlinx.serialization.Serializable
@Serializable
data class Category(
val name: String,
val savePath: String = "",
)
@@ -0,0 +1,180 @@
package amarr.torrent.model
import kotlinx.serialization.Serializable
@Serializable
data class Preferences(
val add_trackers: String = "",
val add_trackers_enabled: Boolean = false,
val alt_dl_limit: Int = 0,
val alt_up_limit: Int = 3072000,
val alternative_webui_enabled: Boolean = false,
val alternative_webui_path: String = "",
val announce_ip: String = "",
val announce_to_all_tiers: Boolean = true,
val announce_to_all_trackers: Boolean = false,
val anonymous_mode: Boolean = false,
val async_io_threads: Int = 10,
val auto_delete_mode: Int = 0,
val auto_tmm_enabled: Boolean = false,
val autorun_enabled: Boolean = false,
val autorun_on_torrent_added_enabled: Boolean = false,
val autorun_on_torrent_added_program: String = "",
val autorun_program: String = "",
val banned_IPs: String = "",
val bittorrent_protocol: Int = 0,
val block_peers_on_privileged_ports: Boolean = false,
val bypass_auth_subnet_whitelist: String = "0.0.0.0/0",
val bypass_auth_subnet_whitelist_enabled: Boolean = true,
val bypass_local_auth: Boolean = true,
val category_changed_tmm_enabled: Boolean = false,
val checking_memory_use: Int = 32,
val connection_speed: Int = 30,
val current_interface_address: String = "",
val current_network_interface: String = "",
val dht: Boolean = true,
val disk_cache: Int = -1,
val disk_cache_ttl: Int = 60,
val disk_io_read_mode: Int = 1,
val disk_io_type: Int = 0,
val disk_io_write_mode: Int = 1,
val disk_queue_size: Int = 1048576,
val dl_limit: Int = 0,
val dont_count_slow_torrents: Boolean = true,
val dyndns_domain: String = "changeme.dyndns.org",
val dyndns_enabled: Boolean = false,
val dyndns_password: String = "",
val dyndns_service: Int = 0,
val dyndns_username: String = "",
val embedded_tracker_port: Int = 9000,
val embedded_tracker_port_forwarding: Boolean = false,
val enable_coalesce_read_write: Boolean = false,
val enable_embedded_tracker: Boolean = false,
val enable_multi_connections_from_same_ip: Boolean = false,
val enable_piece_extent_affinity: Boolean = false,
val enable_upload_suggestions: Boolean = false,
val encryption: Int = 0,
val excluded_file_names: String = "",
val excluded_file_names_enabled: Boolean = false,
val export_dir: String = "",
val export_dir_fin: String = "",
val file_pool_size: Int = 5000,
val hashing_threads: Int = 1,
val idn_support_enabled: Boolean = false,
val incomplete_files_ext: Boolean = false,
val ip_filter_enabled: Boolean = false,
val ip_filter_path: String = "",
val ip_filter_trackers: Boolean = false,
val limit_lan_peers: Boolean = true,
val limit_tcp_overhead: Boolean = false,
val limit_utp_rate: Boolean = true,
val listen_port: Int = 6881,
val locale: String = "en",
val lsd: Boolean = true,
val mail_notification_auth_enabled: Boolean = true,
val mail_notification_email: String = "",
val mail_notification_enabled: Boolean = false,
val mail_notification_password: String = "",
val mail_notification_sender: String = "qBittorrent_notification@example.com",
val mail_notification_smtp: String = "smtp.changeme.com",
val mail_notification_ssl_enabled: Boolean = false,
val mail_notification_username: String = "",
val max_active_checking_torrents: Int = 1,
val max_active_downloads: Int = 20,
val max_active_torrents: Int = 10,
val max_active_uploads: Int = 5,
val max_concurrent_http_announces: Int = 50,
val max_connec: Int = 500,
val max_connec_per_torrent: Int = 100,
val max_ratio: Int = 2,
val max_ratio_act: Int = 3,
val max_ratio_enabled: Boolean = false,
val max_seeding_time: Int = 259200,
val max_seeding_time_enabled: Boolean = false,
val max_uploads: Int = 20,
val max_uploads_per_torrent: Int = 4,
val memory_working_set_limit: Int = 512,
val outgoing_ports_max: Int = 0,
val outgoing_ports_min: Int = 0,
val peer_tos: Int = 4,
val peer_turnover: Int = 4,
val peer_turnover_cutoff: Int = 90,
val peer_turnover_interval: Int = 300,
val performance_warning: Boolean = false,
val pex: Boolean = true,
val preallocate_all: Boolean = false,
val proxy_auth_enabled: Boolean = false,
val proxy_hostname_lookup: Boolean = true,
val proxy_ip: String = "0.0.0.0",
val proxy_password: String = "",
val proxy_peer_connections: Boolean = false,
val proxy_port: Int = 8080,
val proxy_torrents_only: Boolean = false,
val proxy_type: Int = 0,
val proxy_username: String = "",
val queueing_enabled: Boolean = true,
val random_port: Boolean = false,
val reannounce_when_address_changed: Boolean = false,
val recheck_completed_torrents: Boolean = false,
val refresh_interval: Int = 1500,
val request_queue_size: Int = 500,
val resolve_peer_countries: Boolean = true,
val resume_data_storage_type: String = "Legacy",
val rss_auto_downloading_enabled: Boolean = false,
val rss_download_repack_proper_episodes: Boolean = true,
val rss_max_articles_per_feed: Int = 50,
val rss_processing_enabled: Boolean = false,
val rss_refresh_interval: Int = 30,
val rss_smart_episode_filters: String = "s(\\d+)e(\\d+)\n(\\d+)x(\\d+)\n(\\d{4}[.\\-]\\d{1,2}[.\\-]\\d{1,2})\n(\\d{1,2}[.\\-]\\d{1,2}[.\\-]\\d{4})",
val save_path: String,
val save_path_changed_tmm_enabled: Boolean = false,
val save_resume_data_interval: Int = 60,
val schedule_from_hour: Int = 8,
val schedule_from_min: Int = 0,
val schedule_to_hour: Int = 20,
val schedule_to_min: Int = 0,
val scheduler_days: Int = 0,
val scheduler_enabled: Boolean = true,
val send_buffer_low_watermark: Int = 10,
val send_buffer_watermark: Int = 500,
val send_buffer_watermark_factor: Int = 50,
val slow_torrent_dl_rate_threshold: Int = 1000,
val slow_torrent_inactive_timer: Int = 60,
val slow_torrent_ul_rate_threshold: Int = 80,
val socket_backlog_size: Int = 30,
val ssrf_mitigation: Boolean = true,
val start_paused_enabled: Boolean = false,
val stop_tracker_timeout: Int = 5,
val temp_path: String = "/downloads/incomplete",
val temp_path_enabled: Boolean = false,
val torrent_changed_tmm_enabled: Boolean = true,
val torrent_content_layout: String = "Original",
val torrent_stop_condition: String = "None",
val up_limit: Int = 10240000,
val upload_choking_algorithm: Int = 1,
val upload_slots_behavior: Int = 0,
val upnp: Boolean = false,
val upnp_lease_duration: Int = 0,
val use_category_paths_in_manual_mode: Boolean = false,
val use_https: Boolean = false,
val utp_tcp_mixed_mode: Int = 0,
val validate_https_tracker_certificate: Boolean = true,
val web_ui_address: String = "*",
val web_ui_ban_duration: Int = 3600,
val web_ui_clickjacking_protection_enabled: Boolean = true,
val web_ui_csrf_protection_enabled: Boolean = true,
val web_ui_custom_http_headers: String = "",
val web_ui_domain_list: String = "*",
val web_ui_host_header_validation_enabled: Boolean = true,
val web_ui_https_cert_path: String = "",
val web_ui_https_key_path: String = "",
val web_ui_max_auth_fail_count: Int = 5,
val web_ui_port: Int = 8055,
val web_ui_reverse_proxies_list: String = "",
val web_ui_reverse_proxy_enabled: Boolean = false,
val web_ui_secure_cookie_enabled: Boolean = true,
val web_ui_session_timeout: Int = 3600,
val web_ui_upnp: Boolean = true,
val web_ui_use_custom_http_headers_enabled: Boolean = false,
val web_ui_username: String = "oslinux"
)
@@ -0,0 +1,8 @@
package amarr.torrent.model
import kotlinx.serialization.Serializable
@Serializable
data class TorrentFile(
val name: String,
)
@@ -0,0 +1,103 @@
package amarr.torrent.model
import kotlinx.serialization.Serializable
/**
* Full documentation of the qBittorrent API can be found here:
* https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)
*/
@Serializable
data class TorrentInfo(
// Following values are used by Radarr
val hash: String, // Torrent hash
val name: String, // Torrent name
val size: Long, // Total size (bytes) of files selected for download
val progress: Double, // Torrent progress (percentage/100)
val eta: Int, // Torrent ETA (seconds) the value 8640000 indicates that there is no ETA available
val state: TorrentState, // Torrent state
val category: String?, // Category of the torrent
val save_path: String, // Path where this torrent's data is stored
// Following are not used by Radarr but are handled by amarr
val dlspeed: Long, // Torrent download speed (bytes/s)
val num_seeds: Int, // Number of seeders connected to this torrent
val priority: Int, // Torrent priority. Returns -1 if queuing is disabled or torrent is in seed mode
val total_size: Long, // Total size (bytes) of all file in torrent
val downloaded: Long, // Amount of data (bytes) downloaded since torrent was started
// Following are parsed by Radarr but not handled by amarr yet
// TODO: Handle these values
val content_path: String = "", // Subpath where this torrent's data is stored. Only available for multifile torrents
val ratio: Double = 0.0, // Torrent share ratio. Max ratio value: 9999.
val ratio_limit: Int = -2, // Max share ratio until torrent is stopped from seeding/uploading -2 = Use global share ratio limit -1 = Unlimited
val seeding_time: Int = 0, // Total time (seconds) this torrent has been seeding
val seeding_time_limit: Int = -2, // Max seeding time (seconds) until torrent is stopped from seeding -2 = Use global seeding time limit -1 = Unlimited
// TODO This is not parsed by Radarr but should be handled by amarr
val magnet_uri: String = "magnet:?xt=urn:btih:58d3afd393bb1748dc25e24fc680f032a475fa63&dn=Matrix%20HQ%20movie%201998&tr=udp%3a%2f%2ftracker.opentrackr.org%3a1337%2fannounce&tr=https%3a%2f%2ftracker2.ctix.cn%3a443%2fannounce&tr=https%3a%2f%2ftracker1.520.jp%3a443%2fannounce&tr=udp%3a%2f%2fopentracker.i2p.rocks%3a6969%2fannounce&tr=udp%3a%2f%2fopen.tracker.cl%3a1337%2fannounce&tr=udp%3a%2f%2fopen.demonii.com%3a1337%2fannounce&tr=udp%3a%2f%2ftracker.openbittorrent.com%3a6969%2fannounce&tr=http%3a%2f%2ftracker.openbittorrent.com%3a80%2fannounce&tr=udp%3a%2f%2fopen.stealth.si%3a80%2fannounce&tr=udp%3a%2f%2fexodus.desync.com%3a6969%2fannounce&tr=udp%3a%2f%2ftracker.torrent.eu.org%3a451%2fannounce&tr=udp%3a%2f%2ftracker1.bt.moack.co.kr%3a80%2fannounce&tr=udp%3a%2f%2ftracker-udp.gbitt.info%3a80%2fannounce&tr=udp%3a%2f%2fexplodie.org%3a6969%2fannounce&tr=https%3a%2f%2ftracker.gbitt.info%3a443%2fannounce&tr=http%3a%2f%2ftracker.gbitt.info%3a80%2fannounce&tr=http%3a%2f%2fbt.endpot.com%3a80%2fannounce&tr=udp%3a%2f%2ftracker.tiny-vps.com%3a6969%2fannounce&tr=udp%3a%2f%2ftracker.auctor.tv%3a6969%2fannounce&tr=udp%3a%2f%2ftk1.trackerservers.com%3a8080%2fannounce",
// Following values are not handled by amarr
val upspeed: Long = 0, // Torrent upload speed (bytes/s)
val num_leechs: Int = 0, // Number of leechers connected to this torrent
val tags: String = "", // Comma-concatenated tag list of the torrent
val super_seeding: Boolean = false, // True if super seeding is enabled
val added_on: Long = 1696781958, // TODO: Change (UTC timestamp)
val amount_left: Int = 0,
val auto_tmm: Boolean = false,
val availability: Int = 0,
val completed: Int = 0,
val completion_on: Int = 0,
val dl_limit: Int = 0,
val download_path: String = "",
val downloaded_session: Int = 0,
val f_l_piece_prio: Boolean = false,
val force_start: Boolean = false,
val last_activity: Long = 1696781958, // TODO: Change (UTC timestamp)
val max_ratio: Int = -1,
val max_seeding_time: Int = -1,
val num_complete: Int = -1,
val num_incomplete: Int = -1,
val seen_complete: Int = 0,
val seq_dl: Boolean = false,
val time_active: Int = 309,
val tracker: String = "http://tracker.openbittorrent.com:80/announce",
val trackers_count: Int = 20,
val up_limit: Int = 0,
val uploaded: Int = 0,
val uploaded_session: Int = 0,
)
enum class TorrentState {
// Maps to an error in Radarr (Status "Warning")
error, // Some error occurred, applies to paused torrents
stalledDL, // Torrent is being downloaded, but no connection were made
missingFiles, // Torrent data files is missing
// Maps to the "Paused" state in Radarr
pausedDL, // Torrent is paused and has NOT finished downloading
// All map to the "Queued" state in Radarr
queuedDL, // Queuing is enabled and torrent is queued for download
checkingDL, // Same as checkingUP, but torrent has NOT finished downloading
checkingUP, // Torrent has finished downloading and is being checked
checkingResumeData, // Checking resume data on qBt startup
// All map to the "Completed" state in Radarr
pausedUP, // Torrent is paused and has finished downloading
uploading, // Torrent is being seeded and data is being transferred
stalledUP, // Torrent is being seeded, but no connection were made
queuedUP, // Queuing is enabled and torrent is queued for upload
forcedUP, // Torrent is forced to uploading and ignore queue limit
// Maps to the "Queued" state in Radarr only if Dht is enabled, else "Warning"
metaDL, // Torrent has just started downloading and is fetching metadata
// Maps to the "Downloading" state in Radarr
forcedDL, // Torrent is forced to downloading to ignore queue limit
moving, // Torrent is moving to another location
downloading, // Torrent is being downloaded and data is being transferred
// Maps to the "Unknown" state in Radarr
allocating, // Torrent is allocating disk space for download
unknown, // Unknown status
}
@@ -0,0 +1,10 @@
package amarr.torrent.model
import kotlinx.serialization.Serializable
@Serializable
data class TorrentProperties(
val hash: String,
val save_path: String,
val seeding_time: Long,
)
+12
View File
@@ -0,0 +1,12 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
</configuration>
@@ -0,0 +1,37 @@
package amarr
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
import io.kotest.property.checkAll
import io.ktor.http.*
class MagnetLinkTest : StringSpec({
val magnetArb = arbitrary {
val hash = Arb.byteArray(Arb.int(20, 20), Arb.byte()).bind()
val name = Arb.string(1..100).bind()
val size = Arb.long(0..Long.MAX_VALUE).bind()
MagnetLink.forAmarr(hash, name, size)
}
"should create and parse magnet links" {
checkAll(magnetArb) { magnet ->
val parsed = MagnetLink.fromString(magnet.toString())
parsed shouldBe magnet
parsed.isAmarr() shouldBe true
parsed.amuleHexHash().length shouldBe 32
parsed.toEd2kLink() shouldBe "ed2k://|file|${magnet.name.encodeURLParameter()}|${magnet.size}|${magnet.amuleHexHash()}|/"
}
}
"should parse sample ed2k" {
val ed2k = "ed2k://|file|Dj%20Matrix%20&%20Matt%20Joe%20-%20Musica%20da%20giostra,%20Vol.%2010%20(2023).rar|152488462|0320C47B3BAA01F8D5F42CD7C05CE28D|h=O74TQQWUVF24E7WD25UD57Z45GHIDLZZ|/"
val parsed = MagnetLink.fromEd2k(ed2k)
parsed.isAmarr() shouldBe true
parsed.name shouldBe "Dj Matrix & Matt Joe - Musica da giostra, Vol. 10 (2023).rar"
parsed.size shouldBe 152488462
parsed.amuleHexHash().uppercase() shouldBe "0320C47B3BAA01F8D5F42CD7C05CE28D"
}
})
@@ -0,0 +1,71 @@
package amarr.amule
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.testing.*
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import jamule.AmuleClient
import jamule.response.StatsResponse
import kotlinx.serialization.json.Json
class DebugApiKtTest : StringSpec({
val amuleClient = mockk<AmuleClient>()
val sampleStatsResponse = StatsResponse(
bannedCount = 0,
buddyIp = null,
buddyPort = null,
buddyStatus = StatsResponse.BuddyState.Disconnected,
connectionState = null,
downloadOverhead = 0,
downloadSpeed = 0,
downloadSpeedLimit = 0,
ed2kFiles = 0,
ed2kUsers = 0,
kadFiles = 0,
kadFirewalledUdp = false,
kadIndexedKeywords = 0,
kadIndexedLoad = 0,
kadIndexedNotes = 0,
kadIndexedSources = 0,
kadIpAddress = "192.168.3.1",
kadIsRunningInLanMode = false,
kadNodes = 0,
kadUsers = 0,
loggerMessage = emptyList(),
sharedFileCount = 0,
totalReceivedBytes = 0,
totalSentBytes = 0,
totalSourceCount = 0,
uploadOverhead = 0,
uploadQueueLength = 0,
uploadSpeed = 0,
uploadSpeedLimit = 0
)
"should call amule client" {
testApplication {
application {
debugApi(amuleClient)
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
prettyPrint = true
})
}
}
every { amuleClient.getStats() } returns Result.success(sampleStatsResponse)
val response = client.get("/status")
response.status.value shouldBe 200
verify { amuleClient.getStats() }
}
}
})
@@ -0,0 +1,69 @@
package amarr.indexer
import amarr.indexer.implementations.amule.AmuleIndexer
import amarr.indexer.caps.Caps
import amarr.indexer.search.SearchQuery
import amarr.indexer.torznab.torznabApi
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.ktor.client.request.*
import io.ktor.server.testing.*
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
class TorznabApiTest : StringSpec({
val amuleIndexer = mockk<AmuleIndexer>()
val searchQuery = mockk<SearchQuery>()
"should throw exception when missing action" {
testApplication {
application {
torznabApi(amuleIndexer)
}
shouldThrow<IllegalArgumentException> { client.get("/api") }
}
}
"should throw exception on unknown action" {
testApplication {
application {
torznabApi(amuleIndexer)
}
shouldThrow<IllegalArgumentException> { client.get("/api?t=unknown") }
}
}
"should get capabilities from amule indexer when called on /api" {
testApplication {
application {
torznabApi(amuleIndexer)
}
coEvery { amuleIndexer.capabilities() } returns Caps()
client.get("/api?t=caps")
coVerify { amuleIndexer.capabilities() }
}
}
// "should pass query, offset and limits to amule indexer when called on /api" {
// testApplication {
// application {
// torznabApi(amuleIndexer)
// }
// coEvery {
// amuleIndexer.search(
// searchQuery,
// 0,
// 100,
// listOf()
// )
// } returns TorznabFeed(
// channel = TorznabFeed.Channel(
// response = TorznabFeed.Channel.Response(offset = 0, total = 0),
// item = emptyList()
// )
// )
// client.get("/api?t=search&q=test&offset=0&limit=100")
// coVerify { amuleIndexer.search(searchQuery, 0, 100, listOf()) }
// }
// }
})
@@ -0,0 +1,73 @@
package amarr.indexer.indexer
import amarr.indexer.implementations.amule.AmuleIndexer
import amarr.indexer.search.SearchQuery
import amarr.indexer.search.SearchType
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.mockk.Called
import io.mockk.mockk
import io.mockk.verify
import jamule.AmuleClient
import org.slf4j.LoggerFactory
class AmuleIndexerTest : StringSpec({
val mockClient = mockk<AmuleClient>()
val logger = LoggerFactory.getLogger(AmuleIndexerTest::class.java)
"should return single category in capabilities" {
val indexer = AmuleIndexer(mockClient, logger)
val capabilities = indexer.capabilities()
capabilities.categories.category.size shouldBe 1
capabilities.categories.category[0].name shouldBe "All"
capabilities.categories.category[0].id shouldBe 1
}
"when empty queried should return only one result within that category" {
val indexer = AmuleIndexer(mockClient, logger)
val searchQuery= SearchQuery("", SearchType.Search)
val results = indexer.search(searchQuery, 0, 1000, listOf())
results.channel.response.total shouldBe 1
results.channel.response.offset shouldBe 0
results.channel.item.size shouldBe 1
val item = results.channel.item[0]
item.title shouldBe "No query provided"
item.enclosure.url shouldBe "http://mock.url"
item.enclosure.length shouldBe 0
item.attributes.size shouldBe 2
item.attributes[0].name shouldBe "category"
item.attributes[0].value shouldBe "1"
item.attributes[1].name shouldBe "size"
item.attributes[1].value shouldBe "0"
verify { mockClient wasNot Called }
}
// "when queried calls amule client" {
// val searchFile = SearchFile(
// fileName = "test",
// hash = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15),
// sizeFull = 1000,
// completeSourceCount = 1,
// sourceCount = 2,
// downloadStatus = SearchResultsResponse.SearchFileDownloadStatus.NEW,
// )
// every { mockClient.searchSync(any()) } returns Result.success(SearchResultsResponse(listOf(searchFile)))
// val indexer = AmuleIndexer(mockClient, logger)
// val searchQuery= SearchQuery("test", SearchType.Search)
// val result = indexer.search(searchQuery, 0, 1000, listOf())
// verify { mockClient.searchSync("test") }
// result.channel.response.total shouldBe 1
// result.channel.response.offset shouldBe 0
// result.channel.item.size shouldBe 1
// val item = result.channel.item[0]
// item.title shouldBe "test"
// item.enclosure.url shouldBe MagnetLink.forAmarr(searchFile.hash, "test", searchFile.sizeFull).toString()
// item.enclosure.length shouldBe 1000
// item.attributes.size shouldBe 4
// item.attributes shouldContain TorznabAttribute("category", "1")
// item.attributes shouldContain TorznabAttribute("size", "1000")
// item.attributes shouldContain TorznabAttribute("seeders", "1")
// item.attributes shouldContain TorznabAttribute("peers", "2")
// }
})
@@ -0,0 +1,330 @@
package amarr.torrent
import amarr.MagnetLink
import amarr.category.CategoryStore
import amarr.torrent.model.Category
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.testing.*
import io.mockk.clearAllMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import jamule.AmuleClient
import jamule.model.AmuleTransferringFile
import jamule.model.DownloadCommand
import jamule.model.FileStatus
import kotlinx.serialization.json.Json
import java.nio.file.Files
class TorrentApiTest : StringSpec({
val amuleClient = mockk<AmuleClient>()
val categoryStore = MemoryCategoryStore()
val testMagnetHash = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15)
val testMagnetLink = MagnetLink.forAmarr(testMagnetHash, "test", 1)
val finishedPath = "/finished"
beforeAny {
clearAllMocks()
}
"should get preferences" {
testApplication {
application {
torrentApi(amuleClient, categoryStore, finishedPath)
configureForTest()
}
client.get("/api/v2/app/preferences").apply {
this.status shouldBe HttpStatusCode.OK
}
}
}
"should get api version" {
testApplication {
application {
torrentApi(amuleClient, categoryStore, finishedPath)
configureForTest()
}
client.get("/api/v2/app/webapiVersion").apply {
this.status shouldBe HttpStatusCode.OK
}
}
}
"should allow login" {
testApplication {
application {
torrentApi(amuleClient, categoryStore, finishedPath)
configureForTest()
}
client.submitForm(formParameters = Parameters.build {
append("username", "test")
append("password", "test")
}, url = "/api/v2/auth/login").apply {
this.status shouldBe HttpStatusCode.OK
}
}
}
"should add torrent" {
testApplication {
application {
torrentApi(amuleClient, categoryStore, finishedPath)
configureForTest()
}
val urls = listOf(testMagnetLink.toString())
val ed2k = testMagnetLink.toEd2kLink()
every { amuleClient.downloadEd2kLink(ed2k) } returns Result.success(Unit)
client.submitForm(formParameters = Parameters.build {
appendAll("urls", urls)
append("category", "test")
append("paused", "test")
}, url = "/api/v2/torrents/add").apply {
this.status shouldBe HttpStatusCode.OK
}
}
}
"should get categories" {
testApplication {
application {
torrentApi(amuleClient, categoryStore, finishedPath)
configureForTest()
}
client.get("/api/v2/torrents/categories").apply {
this.status shouldBe HttpStatusCode.OK
}
}
}
"should create category" {
testApplication {
application {
torrentApi(amuleClient, categoryStore, finishedPath)
configureForTest()
}
client.submitForm(formParameters = Parameters.build {
append("category", "test")
append("savePath", "test")
}, url = "/api/v2/torrents/createCategory").apply {
this.status shouldBe HttpStatusCode.OK
}
}
}
"should delete torrent when downloading" {
testApplication {
application {
torrentApi(amuleClient, categoryStore, finishedPath)
configureForTest()
}
categoryStore.store("test", testMagnetLink.amuleHexHash())
every {
amuleClient.sendDownloadCommand(testMagnetHash, DownloadCommand.DELETE)
} returns Result.success(Unit)
every {
amuleClient.getDownloadQueue()
} returns Result.success(
listOf(
MockTransferringFile(
fileHashHexString = testMagnetLink.amuleHexHash(),
fileName = testMagnetLink.name,
sizeFull = testMagnetLink.size,
)
)
)
client.submitForm(formParameters = Parameters.build {
append("hashes", testMagnetLink.amuleHexHash())
append("deleteFiles", "true")
}, url = "/api/v2/torrents/delete").apply {
this.status shouldBe HttpStatusCode.OK
}
verify { amuleClient.sendDownloadCommand(testMagnetHash, DownloadCommand.DELETE) }
categoryStore.getCategory(testMagnetLink.amuleHexHash()) shouldBe null
}
}
"should delete file when not downloading" {
testApplication {
application {
torrentApi(amuleClient, categoryStore, finishedPath)
configureForTest()
}
categoryStore.store("test", testMagnetLink.amuleHexHash())
every {
amuleClient.sendDownloadCommand(testMagnetHash, DownloadCommand.DELETE)
} returns Result.success(Unit)
val randomTemporaryFile = Files.createTempFile("test", "test")
every { amuleClient.getSharedFiles() } returns Result.success(
listOf(
MockTransferringFile(
fileHashHexString = testMagnetLink.amuleHexHash(),
fileName = testMagnetLink.name,
sizeFull = testMagnetLink.size,
filePath = randomTemporaryFile.toAbsolutePath().toString()
)
)
)
every { amuleClient.getDownloadQueue() } returns Result.success(emptyList())
client.submitForm(formParameters = Parameters.build {
append("hashes", testMagnetLink.amuleHexHash())
append("deleteFiles", "true")
}, url = "/api/v2/torrents/delete").apply {
this.status shouldBe HttpStatusCode.OK
}
verify(exactly = 0) { amuleClient.sendDownloadCommand(testMagnetHash, DownloadCommand.DELETE) }
categoryStore.getCategory(testMagnetLink.amuleHexHash()) shouldBe null
Files.exists(randomTemporaryFile) shouldBe false
}
}
"should get files" {
testApplication {
application {
torrentApi(amuleClient, categoryStore, finishedPath)
configureForTest()
}
amuleClient.addToDownloadQueue(testMagnetLink)
every { amuleClient.getSharedFiles() } returns Result.success(emptyList())
client.get {
url("/api/v2/torrents/files")
parameter("hash", testMagnetLink.amuleHexHash())
}.apply {
this.status shouldBe HttpStatusCode.OK
}
}
}
"should get info" {
testApplication {
application {
torrentApi(amuleClient, categoryStore, finishedPath)
configureForTest()
}
amuleClient.addToDownloadQueue(testMagnetLink)
every { amuleClient.getSharedFiles() } returns Result.success(emptyList())
client.get {
url("/api/v2/torrents/info")
parameter("category", "test")
}.apply {
this.status shouldBe HttpStatusCode.OK
}
}
}
"should get properties" {
testApplication {
application {
torrentApi(amuleClient, categoryStore, finishedPath)
configureForTest()
}
amuleClient.addToDownloadQueue(testMagnetLink)
every { amuleClient.getSharedFiles() } returns Result.success(emptyList())
client.get {
url("/api/v2/torrents/properties")
parameter("hash", testMagnetLink.amuleHexHash())
}.apply {
this.status shouldBe HttpStatusCode.OK
}
}
}
})
private fun AmuleClient.addToDownloadQueue(magnetLink: MagnetLink) {
every { this@addToDownloadQueue.getDownloadQueue() } returns Result.success(
listOf(
MockTransferringFile(
fileHashHexString = magnetLink.amuleHexHash(),
fileName = magnetLink.name,
sizeFull = magnetLink.size,
)
)
)
}
private fun Application.configureForTest() {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
prettyPrint = true
encodeDefaults = true
})
}
}
private class MemoryCategoryStore() : CategoryStore {
private val categories = mutableSetOf<Category>()
private val hashes = mutableMapOf<String, String>()
override fun store(category: String, hash: String) {
hashes[hash] = category
}
override fun getCategory(hash: String): String? {
return hashes[hash]
}
override fun delete(hash: String) {
hashes.remove(hash)
}
override fun addCategory(category: Category) {
categories.add(category)
}
override fun getCategories(): Set<Category> {
return categories
}
}
private data class MockTransferringFile(
override val fileHashHexString: String? = null,
override val partMetID: Short? = 0,
override val sizeXfer: Long? = 0,
override val sizeDone: Long? = 0,
override val fileStatus: FileStatus = FileStatus.UNKNOWN,
override val stopped: Boolean = false,
override val sourceCount: Short = 0,
override val sourceNotCurrCount: Short = 0,
override val sourceXferCount: Short = 0,
override val sourceCountA4AF: Short = 0,
override val speed: Long? = 0,
override val downPrio: Byte = 0,
override val fileCat: Long = 0,
override val lastSeenComplete: Long = 0,
override val lastDateChanged: Long = 0,
override val downloadActiveTime: Int = 0,
override val availablePartCount: Short = 0,
override val a4AFAuto: Boolean = false,
override val hashingProgress: Boolean = false,
override val getLostDueToCorruption: Long = 0,
override val getGainDueToCompression: Long = 0,
override val totalPacketsSavedDueToICH: Int = 0,
override val fileName: String? = null,
override val filePath: String? = null,
override val sizeFull: Long? = 0,
override val fileEd2kLink: String? = null,
override val upPrio: Byte = 0,
override val getRequests: Short = 0,
override val getAllRequests: Int = 0,
override val getAccepts: Short = 0,
override val getAllAccepts: Int = 0,
override val getXferred: Long = 0,
override val getAllXferred: Long = 0,
override val getCompleteSourcesLow: Short = 0,
override val getCompleteSourcesHigh: Short = 0,
override val getCompleteSources: Short = 0,
override val getOnQueue: Short = 0,
override val getComment: String? = null,
override val getRating: Byte? = 0,
) : AmuleTransferringFile
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration>
<configuration>
<import class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"/>
<import class="ch.qos.logback.core.ConsoleAppender"/>
<appender name="STDOUT" class="ConsoleAppender">
<encoder class="PatternLayoutEncoder">
<pattern>%d{ss.SSS} %-5level %logger{36} -%kvp- %msg%n</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="STDOUT"/>
</root>
</configuration>