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:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user