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
+11
View File
@@ -0,0 +1,11 @@
#
# https://help.github.com/articles/dealing-with-line-endings/
#
# Linux start script should use lf
/gradlew text eol=lf
# These are Windows script files and should use crlf
*.bat text eol=crlf
# Do not use the following files for linguist statistics
*.html linguist-vendored
+12
View File
@@ -0,0 +1,12 @@
# Ignore Gradle project-specific cache directory
.gradle
# Ignore Gradle build output directory
build
# Ignore IntelliJ IDEA project-specific settings directory
.idea
config/
node_modules/
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2025 Daniel Chenel
Copyright (c) 2023 Luca Vitucci
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+80
View File
@@ -0,0 +1,80 @@
# Amarr - aMule *arr Connector
[Docker Hub Image](https://hub.docker.com/r/danielchc/amarr)
This connector allows using aMule as a download client for [Sonarr](https://sonarr.tv/)
and [Radarr](https://radarr.video/).
It works by emulating a torrent client, so Sonarr and Radarr will manage your downloads as if they were torrents.
Makes use of [jaMule](https://github.com/vexdev/jaMule) to connect to aMule, which only supports aMule versions **2.3.1** to **2.3.3**.
## Pre-requisites
- [aMule](https://www.amule.org/) version **2.3.1** to **2.3.3** running and configured
- [Sonarr](https://sonarr.tv/) or [Radarr](https://radarr.video/) running and configured
**Amarr does not come with its own amule installation**, you need to have it running and configured.
One way to do this is by using the [Amule Docker image from ngosang](https://github.com/ngosang/docker-amule).
Or again you could run aMule in a VM or in a physical machine.
## Installation
It requires the following environment variables:
```
AMULE_HOST: aMule # The host where aMule is running, for docker containers it's usually the name of the container
AMULE_PORT: 4712 # The port where aMule is listening with the EC protocol
AMULE_PASSWORD: secret # The password to connect to aMule
Optional parameters:
AMULE_FINISHED_PATH: /finished # The directory where aMule will download the finished files
AMARR_LOG_LEVEL: INFO # The log level of amarr, defaults to INFO
```
## Radarr/Sonarr configuration
### 1. Configure amarr as a download client
You will need to add the download client.
You can do that by adding a new download client of type **qBittorrent** with the following settings:
```
! Ensure you pressed the "Show advanced settings" button
Name: Any name you want
Host: amarr # The host where amarr is running, for docker containers it's usually the name of the container
Port: 4713 # The port where amarr is listening
Priority: 50 # This is the lowest possible priority, so Sonarr/Radarr will prefer other download clients
```
### 2. Configure amarr as a torrent indexer
Amarr provides multiple indexers with different capabilities.
Each indexer implements the **Torznab** protocol, so it can be used as a torrent indexer for Sonarr/Radarr.
Add a new **Torznab indexer** with the following settings:
```
! Ensure you pressed the "Show advanced settings" button
Name: Any name you want
Url: http://amarr:4713/indexer/<indexer>
Download Client: The name you gave to amarr in the previous step
```
**Note:** You need to configure Sonarr/Radarr to prefer amarr as a download client for any indexers we created before.
**Note:** `<indexer-type>` is [one of the indexers supported by amarr](#indexers).
**You will have to do this for every indexer you want to use with amarr.**
## Indexers
### `amule`
This indexer will search for files in aMule through the kad/eD2k network.
It is very slow and not very reliable. Additionally, files on the kad/eD2k network are not well reviewed, so you may end
up downloading fake files.
Does not require any additional configuration.
+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>
+14
View File
@@ -0,0 +1,14 @@
services:
amarr:
image: danielchc/amarr
container_name: amarr
environment:
- AMULE_HOST=aMule
- AMULE_PORT=4712
- AMULE_PASSWORD=secret
volumes:
- ammar-config:/config
ports:
- 4713:4713
volumes:
ammar-config:
+1
View File
@@ -0,0 +1 @@
version=1.2
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+249
View File
@@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+92
View File
@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+6505
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"release": {
"branches": [
"main",
"develop"
],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/github",
"@semantic-release/git",
"@semantic-release/changelog",
[
"@semantic-release/exec",
{
"verifyConditionsCmd": "./gradlew clean test",
"prepareCmd": "./gradlew -Pversion=${nextRelease.version} clean build",
"publishCmd": "./gradlew -Pversion=${nextRelease.version} jib"
}
]
]
},
"dependencies": {
"semantic-release": "latest",
"@semantic-release/commit-analyzer": "latest",
"@semantic-release/exec": "latest",
"@semantic-release/git": "latest",
"@semantic-release/github": "latest",
"@semantic-release/release-notes-generator": "latest",
"@semantic-release/changelog": "latest"
}
}
+73
View File
@@ -0,0 +1,73 @@
plugins {
// Apply the foojay-resolver plugin to allow automatic download of JDKs
id("org.gradle.toolchains.foojay-resolver-convention") version "0.7.0"
}
rootProject.name = "amarr"
include("app")
dependencyResolutionManagement {
versionCatalogs {
create("libs") {
// Versions
version("ktor", "2.3.7")
version("kotlin", "1.9.21")
version("bt", "1.10")
version("kotest", "5.8.0")
// Libraries
library("ktor-server-core", "io.ktor", "ktor-server-core").versionRef("ktor")
library("ktor-server-netty", "io.ktor", "ktor-server-netty").versionRef("ktor")
library("ktor-server-content-negotiation", "io.ktor", "ktor-server-content-negotiation").versionRef("ktor")
library("ktor-server-call-logging", "io.ktor", "ktor-server-call-logging").versionRef("ktor")
library("ktor-server-call-logging-jvm", "io.ktor", "ktor-server-call-logging-jvm").versionRef("ktor")
library("ktor-server-default-headers-jvm", "io.ktor", "ktor-server-default-headers-jvm").versionRef("ktor")
library("ktor-serialization-kotlinx-xml", "io.ktor", "ktor-serialization-kotlinx-xml").versionRef("ktor")
library("ktor-serialization-kotlinx-json", "io.ktor", "ktor-serialization-kotlinx-json").versionRef("ktor")
library("ktor-server-test-host-jvm", "io.ktor", "ktor-server-test-host-jvm").versionRef("ktor")
library("ktor-client-core", "io.ktor", "ktor-client-core").versionRef("ktor")
library("ktor-client-cio", "io.ktor", "ktor-client-cio").versionRef("ktor")
library("ktor-client-logging", "io.ktor", "ktor-client-logging").versionRef("ktor")
library("ktor-client-mock", "io.ktor", "ktor-client-mock").versionRef("ktor")
library("jamule", "com.vexdev", "jamule").version("1.0.3")
library("guava", "com.google.guava", "guava").version("32.1.3-jre")
library("kotest-runner-junit5", "io.kotest", "kotest-runner-junit5").versionRef("kotest")
library("kotest-assertions-core", "io.kotest", "kotest-assertions-core").versionRef("kotest")
library("kotest-property", "io.kotest", "kotest-property").versionRef("kotest")
library("mockk", "io.mockk", "mockk").version("1.13.8")
library("logback", "ch.qos.logback", "logback-classic").version("1.4.14")
library("kotlin-test-junit", "org.jetbrains.kotlin", "kotlin-test-junit").versionRef("kotlin")
library("commons-text", "org.apache.commons", "commons-text").version("1.11.0")
// Plugins
plugin("kotlin-jvm", "org.jetbrains.kotlin.jvm").versionRef("kotlin")
plugin("kotlin-serialization", "org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin")
plugin("jib", "com.google.cloud.tools.jib").version("3.4.0")
// Bundles
bundle("kotest", listOf("kotest-runner-junit5", "kotest-assertions-core", "kotest-property"))
bundle(
"ktor-server",
listOf(
"ktor-server-core",
"ktor-server-netty",
"ktor-server-content-negotiation",
"ktor-server-call-logging",
"ktor-server-call-logging-jvm",
"ktor-server-default-headers-jvm",
"ktor-serialization-kotlinx-xml",
"ktor-serialization-kotlinx-json"
)
)
bundle(
"ktor-client",
listOf(
"ktor-client-core",
"ktor-client-cio",
"ktor-client-logging"
)
)
}
}
}