├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── app ├── src │ ├── main │ │ ├── kotlin │ │ │ └── amarr │ │ │ │ ├── torznab │ │ │ │ ├── indexer │ │ │ │ │ ├── ThrottledException.kt │ │ │ │ │ ├── UnauthorizedException.kt │ │ │ │ │ ├── Indexer.kt │ │ │ │ │ ├── AmuleIndexer.kt │ │ │ │ │ └── ddunlimitednet │ │ │ │ │ │ ├── DdunlimitednetIndexer.kt │ │ │ │ │ │ └── DdunlimitednetClient.kt │ │ │ │ ├── model │ │ │ │ │ ├── Feed.kt │ │ │ │ │ └── Caps.kt │ │ │ │ └── TorznabApi.kt │ │ │ │ ├── torrent │ │ │ │ ├── model │ │ │ │ │ ├── TorrentFile.kt │ │ │ │ │ ├── Category.kt │ │ │ │ │ ├── TorrentProperties.kt │ │ │ │ │ ├── TorrentInfo.kt │ │ │ │ │ └── Preferences.kt │ │ │ │ ├── TorrentApi.kt │ │ │ │ └── TorrentService.kt │ │ │ │ ├── category │ │ │ │ ├── CategoryStore.kt │ │ │ │ └── FileCategoryStore.kt │ │ │ │ ├── App.kt │ │ │ │ ├── MagnetLink.kt │ │ │ │ └── amule │ │ │ │ └── DebugApi.kt │ │ └── resources │ │ │ └── logback.xml │ └── test │ │ ├── resources │ │ └── logback-test.xml │ │ └── kotlin │ │ └── amarr │ │ ├── MagnetLinkTest.kt │ │ ├── torznab │ │ ├── indexer │ │ │ ├── ddunlimitednet │ │ │ │ └── DdunlimitednetClientTest.kt │ │ │ ├── DdunlimitednetIndexerTest.kt │ │ │ └── AmuleIndexerTest.kt │ │ └── TorznabApiTest.kt │ │ ├── amule │ │ └── DebugApiKtTest.kt │ │ └── torrent │ │ └── TorrentApiTest.kt └── build.gradle.kts ├── .github ├── renovate.json └── workflows │ ├── validate.yml │ ├── release.yml │ └── renovate.yml ├── .gitignore ├── .gitattributes ├── package.json ├── LICENSE.md ├── gradlew.bat ├── settings.gradle.kts ├── README.md └── gradlew /gradle.properties: -------------------------------------------------------------------------------- 1 | version=unset -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vexdev/amarr/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/torznab/indexer/ThrottledException.kt: -------------------------------------------------------------------------------- 1 | package amarr.torznab.indexer 2 | 3 | class ThrottledException : Exception() -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/torznab/indexer/UnauthorizedException.kt: -------------------------------------------------------------------------------- 1 | package amarr.torznab.indexer 2 | 3 | class UnauthorizedException : Exception() -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseBranches": [ 3 | "main" 4 | ], 5 | "rebaseWhen": "conflicted", 6 | "labels": [ 7 | "dependencies" 8 | ], 9 | "automergeStrategy": "merge-commit" 10 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/torrent/model/TorrentFile.kt: -------------------------------------------------------------------------------- 1 | package amarr.torrent.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class TorrentFile( 7 | val name: String, 8 | ) 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore Gradle project-specific cache directory 2 | .gradle 3 | 4 | # Ignore Gradle build output directory 5 | build 6 | 7 | # Ignore IntelliJ IDEA project-specific settings directory 8 | .idea 9 | 10 | config/ 11 | 12 | node_modules/ -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/torrent/model/Category.kt: -------------------------------------------------------------------------------- 1 | package amarr.torrent.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class Category( 7 | val name: String, 8 | val savePath: String = "", 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/torrent/model/TorrentProperties.kt: -------------------------------------------------------------------------------- 1 | package amarr.torrent.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class TorrentProperties( 7 | val hash: String, 8 | val save_path: String, 9 | val seeding_time: Long, 10 | ) 11 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # 2 | # https://help.github.com/articles/dealing-with-line-endings/ 3 | # 4 | # Linux start script should use lf 5 | /gradlew text eol=lf 6 | 7 | # These are Windows script files and should use crlf 8 | *.bat text eol=crlf 9 | 10 | # Do not use the following files for linguist statistics 11 | *.html linguist-vendored -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/category/CategoryStore.kt: -------------------------------------------------------------------------------- 1 | package amarr.category 2 | 3 | import amarr.torrent.model.Category 4 | 5 | interface CategoryStore { 6 | fun store(category: String, hash: String) 7 | fun getCategory(hash: String): String? 8 | fun delete(hash: String) 9 | fun addCategory(category: Category) 10 | fun getCategories(): Set 11 | } -------------------------------------------------------------------------------- /app/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/torznab/indexer/Indexer.kt: -------------------------------------------------------------------------------- 1 | package amarr.torznab.indexer 2 | 3 | import amarr.torznab.model.Caps 4 | import amarr.torznab.model.Feed 5 | 6 | interface Indexer { 7 | 8 | /** 9 | * Given a paginated query, returns a [Feed] with the results. 10 | */ 11 | suspend fun search(query: String, offset: Int, limit: Int, cat: List): Feed 12 | 13 | /** 14 | * Returns the capabilities of this indexer. 15 | */ 16 | suspend fun capabilities(): Caps 17 | 18 | } -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | on: 3 | push: { } 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - uses: actions/setup-java@v4 10 | with: 11 | java-version: '17' 12 | distribution: 'temurin' 13 | - name: Validate Gradle wrapper 14 | uses: gradle/wrapper-validation-action@v1.1.0 15 | - name: Run the Gradle test task 16 | uses: gradle/gradle-build-action@v2.11.0 17 | with: 18 | arguments: test 19 | -------------------------------------------------------------------------------- /app/src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | %d{ss.SSS} %-5level %logger{36} -%kvp- %msg%n 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "release": { 3 | "branches": [ 4 | "main", 5 | "develop" 6 | ], 7 | "plugins": [ 8 | "@semantic-release/commit-analyzer", 9 | "@semantic-release/release-notes-generator", 10 | "@semantic-release/github", 11 | "@semantic-release/git", 12 | "@semantic-release/changelog", 13 | [ 14 | "@semantic-release/exec", 15 | { 16 | "verifyConditionsCmd": "./gradlew clean test", 17 | "prepareCmd": "./gradlew -Pversion=${nextRelease.version} clean build", 18 | "publishCmd": "./gradlew -Pversion=${nextRelease.version} jib" 19 | } 20 | ] 21 | ] 22 | }, 23 | "dependencies": { 24 | "semantic-release": "latest", 25 | "@semantic-release/commit-analyzer": "latest", 26 | "@semantic-release/exec": "latest", 27 | "@semantic-release/git": "latest", 28 | "@semantic-release/github": "latest", 29 | "@semantic-release/release-notes-generator": "latest", 30 | "@semantic-release/changelog": "latest" 31 | } 32 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Luca Vitucci 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | permissions: 8 | contents: read # for checkout 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write # to be able to publish a GitHub release 16 | issues: write # to be able to comment on released issues 17 | pull-requests: write # to be able to comment on released pull requests 18 | id-token: write # to enable use of OIDC for npm provenance 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - name: Setup Node.js 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: "lts/*" 28 | - name: Install dependencies 29 | run: npm clean-install 30 | - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies 31 | run: npm audit signatures 32 | - name: Release 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 35 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 36 | DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 37 | run: npx semantic-release 38 | -------------------------------------------------------------------------------- /.github/workflows/renovate.yml: -------------------------------------------------------------------------------- 1 | name: Renovate 2 | on: 3 | schedule: 4 | - cron: '0 3 * * *' 5 | workflow_dispatch: 6 | inputs: 7 | logLevel: 8 | description: "Override default log level" 9 | required: false 10 | default: "info" 11 | type: string 12 | overrideSchedule: 13 | description: "Override all schedules" 14 | required: false 15 | default: "false" 16 | type: string 17 | jobs: 18 | renovate: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4.1.1 23 | - name: Validate Renovate JSON 24 | run: jq type .github/renovate.json 25 | - name: Get token 26 | id: get_token 27 | uses: tibdex/github-app-token@v2.1.0 28 | with: 29 | app_id: ${{ secrets.RENOVATE_APP_ID }} 30 | private_key: ${{ secrets.RENOVATE_PRIVATE_KEY }} 31 | - name: Self-hosted Renovate 32 | uses: renovatebot/github-action@v39.2.3 33 | env: 34 | RENOVATE_REPOSITORIES: ${{ github.repository }} 35 | RENOVATE_ONBOARDING: "false" 36 | RENOVATE_USERNAME: "vexdev-renovate[bot]" 37 | RENOVATE_GIT_AUTHOR: "vexdev-renovate <140329261+vexdev-renovate[bot]@users.noreply.github.com>" 38 | RENOVATE_PLATFORM_COMMIT: "true" 39 | RENOVATE_FORCE: ${{ github.event.inputs.overrideSchedule == 'true' && '{''schedule'':null}' || '' }} 40 | LOG_LEVEL: ${{ inputs.logLevel || 'info' }} 41 | with: 42 | configurationFile: .github/renovate.json 43 | token: '${{ steps.get_token.outputs.token }}' -------------------------------------------------------------------------------- /app/src/test/kotlin/amarr/MagnetLinkTest.kt: -------------------------------------------------------------------------------- 1 | package amarr 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.kotest.property.Arb 6 | import io.kotest.property.arbitrary.* 7 | import io.kotest.property.checkAll 8 | import io.ktor.http.* 9 | 10 | class MagnetLinkTest : StringSpec({ 11 | 12 | val magnetArb = arbitrary { 13 | val hash = Arb.byteArray(Arb.int(20, 20), Arb.byte()).bind() 14 | val name = Arb.string(1..100).bind() 15 | val size = Arb.long(0..Long.MAX_VALUE).bind() 16 | MagnetLink.forAmarr(hash, name, size) 17 | } 18 | 19 | "should create and parse magnet links" { 20 | checkAll(magnetArb) { magnet -> 21 | val parsed = MagnetLink.fromString(magnet.toString()) 22 | parsed shouldBe magnet 23 | parsed.isAmarr() shouldBe true 24 | parsed.amuleHexHash().length shouldBe 32 25 | parsed.toEd2kLink() shouldBe "ed2k://|file|${magnet.name.encodeURLParameter()}|${magnet.size}|${magnet.amuleHexHash()}|/" 26 | } 27 | } 28 | 29 | "should parse sample ed2k" { 30 | val ed2k = "ed2k://|file|Dj%20Matrix%20&%20Matt%20Joe%20-%20Musica%20da%20giostra,%20Vol.%2010%20(2023).rar|152488462|0320C47B3BAA01F8D5F42CD7C05CE28D|h=O74TQQWUVF24E7WD25UD57Z45GHIDLZZ|/" 31 | val parsed = MagnetLink.fromEd2k(ed2k) 32 | parsed.isAmarr() shouldBe true 33 | parsed.name shouldBe "Dj Matrix & Matt Joe - Musica da giostra, Vol. 10 (2023).rar" 34 | parsed.size shouldBe 152488462 35 | parsed.amuleHexHash().uppercase() shouldBe "0320C47B3BAA01F8D5F42CD7C05CE28D" 36 | } 37 | }) -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.jvm) 3 | alias(libs.plugins.kotlin.serialization) 4 | alias(libs.plugins.jib) 5 | application 6 | } 7 | 8 | println("Version is $version") 9 | 10 | repositories { 11 | mavenCentral() 12 | mavenLocal() 13 | } 14 | 15 | dependencies { 16 | implementation(libs.bundles.ktor.server) 17 | implementation(libs.bundles.ktor.client) 18 | implementation(libs.jamule) 19 | implementation(libs.guava) 20 | implementation(libs.logback) 21 | implementation(libs.commons.text) 22 | 23 | testImplementation(libs.bundles.kotest) 24 | testImplementation(libs.mockk) 25 | testImplementation(libs.ktor.server.test.host.jvm) 26 | testImplementation(libs.ktor.client.mock) 27 | testImplementation(libs.kotlin.test.junit) 28 | } 29 | 30 | java { 31 | toolchain { 32 | languageVersion.set(JavaLanguageVersion.of(17)) 33 | } 34 | } 35 | 36 | application { 37 | mainClass.set("amarr.AppKt") 38 | } 39 | 40 | tasks.named("test") { 41 | useJUnitPlatform() 42 | } 43 | 44 | jib { 45 | from { 46 | image = "openjdk:17-jdk-slim" 47 | platforms { 48 | platform { 49 | architecture = "amd64" 50 | os = "linux" 51 | } 52 | platform { 53 | architecture = "arm64" 54 | os = "linux" 55 | } 56 | } 57 | } 58 | to { 59 | image = "vexdev/amarr" 60 | tags = setOf(version.toString()) 61 | auth { 62 | username = System.getenv("DOCKER_USERNAME") 63 | password = System.getenv("DOCKER_PASSWORD") 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /app/src/test/kotlin/amarr/torznab/indexer/ddunlimitednet/DdunlimitednetClientTest.kt: -------------------------------------------------------------------------------- 1 | package amarr.torznab.indexer.ddunlimitednet 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.ktor.client.engine.mock.* 6 | import io.ktor.http.HttpStatusCode.Companion.OK 7 | import io.ktor.utils.io.* 8 | import org.slf4j.LoggerFactory 9 | 10 | class DdunlimitednetClientTest : StringSpec({ 11 | val logger = LoggerFactory.getLogger(this::class.java) 12 | 13 | "should parse standard search" { 14 | val html = this::class.java.getResource("/ddunlimitednet/search-matrix.html")!!.readText() 15 | 16 | val mockEngine = MockEngine { _ -> respond(ByteReadChannel(html), OK) } 17 | val client = DdunlimitednetClient(mockEngine, "user", "pass", logger) 18 | 19 | val result = client.search("matrix", listOf()) 20 | 21 | result.isSuccess shouldBe true 22 | result.getOrThrow().size shouldBe 110 23 | } 24 | 25 | "should decode html and remove tags" { 26 | val html = 27 | "ed2k://|file|Dj%20Matrix%20&%20Matt%20Joe%20-%20Musica%20da%20giostra,%20Vol.%2010%20(2023).rar|152488462|0320C47B3BAA01F8D5F42CD7C05CE28D|h=O74TQQWUVF24E7WD25UD57Z45GHIDLZZ|/" 28 | 29 | val mockEngine = MockEngine { _ -> respond(ByteReadChannel(html), OK) } 30 | val client = DdunlimitednetClient(mockEngine, "user", "pass", logger) 31 | 32 | val result = client.search("matrix", listOf()) 33 | 34 | result.isSuccess shouldBe true 35 | result.getOrThrow().size shouldBe 1 36 | result.getOrThrow()[0] shouldBe "ed2k://|file|Dj%20Matrix%20&%20Matt%20Joe%20-%20Musica%20da%20giostra,%20Vol.%2010%20(2023).rar|152488462|0320C47B3BAA01F8D5F42CD7C05CE28D|h=O74TQQWUVF24E7WD25UD57Z45GHIDLZZ|/" 37 | } 38 | 39 | }) 40 | -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/torznab/model/Feed.kt: -------------------------------------------------------------------------------- 1 | package amarr.torznab.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | import nl.adaptivity.xmlutil.ExperimentalXmlUtilApi 6 | import nl.adaptivity.xmlutil.serialization.XmlElement 7 | import nl.adaptivity.xmlutil.serialization.XmlNamespaceDeclSpec 8 | import nl.adaptivity.xmlutil.serialization.XmlSerialName 9 | 10 | @OptIn(ExperimentalXmlUtilApi::class) 11 | @Serializable 12 | @XmlNamespaceDeclSpec("${Feed.TORZNAB_PREFIX}=${Feed.TORZNAB_NAMESPACE}") 13 | @SerialName("rss") 14 | data class Feed(val version: String = "2.0", val channel: Channel) { 15 | 16 | @Serializable 17 | @SerialName("channel") 18 | data class Channel( 19 | @XmlElement 20 | val title: String = "Amarr", 21 | @XmlElement 22 | val description: String = "Amarr 1.0", 23 | val response: Response, 24 | val item: List 25 | ) { 26 | 27 | @Serializable 28 | @XmlSerialName("response", TORZNAB_NAMESPACE, TORZNAB_PREFIX) 29 | data class Response( 30 | val offset: Int, 31 | val total: Int, 32 | ) 33 | 34 | @Serializable 35 | @SerialName("item") 36 | data class Item( 37 | @XmlElement 38 | val title: String, 39 | @XmlElement 40 | val pubDate: String = "Sat, 14 Mar 2015 12:42:19 -0400", 41 | val enclosure: Enclosure, 42 | val attributes: List 43 | ) { 44 | 45 | @Serializable 46 | @SerialName("enclosure") 47 | data class Enclosure( 48 | val url: String, 49 | val length: Long, 50 | val type: String = "application/x-bittorrent" 51 | ) 52 | 53 | @Serializable 54 | @XmlSerialName("attr", TORZNAB_NAMESPACE, TORZNAB_PREFIX) 55 | data class TorznabAttribute( 56 | val name: String, 57 | val value: String, 58 | ) 59 | } 60 | } 61 | 62 | companion object { 63 | const val TORZNAB_NAMESPACE = "http://torznab.com/schemas/2015/feed" 64 | const val TORZNAB_PREFIX = "torznab" 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /app/src/test/kotlin/amarr/torznab/indexer/DdunlimitednetIndexerTest.kt: -------------------------------------------------------------------------------- 1 | package amarr.torznab.indexer 2 | 3 | import amarr.torznab.indexer.ddunlimitednet.DdunlimitednetClient 4 | import amarr.torznab.indexer.ddunlimitednet.DdunlimitednetIndexer 5 | import amarr.torznab.model.Feed 6 | import io.kotest.core.spec.style.StringSpec 7 | import io.kotest.matchers.shouldBe 8 | import io.mockk.coEvery 9 | import io.mockk.mockk 10 | import org.slf4j.LoggerFactory 11 | 12 | class DdunlimitednetIndexerTest : StringSpec({ 13 | val client = mockk() 14 | val logger = LoggerFactory.getLogger(this::class.java) 15 | val sampleLink = 16 | "ed2k://|file|Dj%20Matrix%20&%20Matt%20Joe%20-%20Musica%20da%20giostra,%20Vol.%2010%20(2023).rar|152488462|0320C47B3BAA01F8D5F42CD7C05CE28D|h=O74TQQWUVF24E7WD25UD57Z45GHIDLZZ|/" 17 | 18 | "should convert links to feed items" { 19 | val tested = DdunlimitednetIndexer(client, logger) 20 | coEvery { client.search(any(), listOf()) } returns Result.success(listOf(sampleLink)) 21 | 22 | val result = tested.search("matrix", 0, 100, listOf()) 23 | 24 | result shouldBe Feed( 25 | channel = Feed.Channel( 26 | response = Feed.Channel.Response( 27 | offset = 0, 28 | total = 1 29 | ), 30 | item = listOf( 31 | Feed.Channel.Item( 32 | title = "Dj Matrix & Matt Joe - Musica da giostra, Vol. 10 (2023).rar", 33 | enclosure = Feed.Channel.Item.Enclosure( 34 | url = "magnet:?xt=urn:btih:AMQMI6Z3VIA7RVPUFTL4AXHCRUAAAAAA&dn=Dj%20Matrix%20%26%20Matt%20Joe%20-%20Musica%20da%20giostra%2C%20Vol.%2010%20%282023%29.rar&xl=152488462&tr=http%3A%2F%2Famarr-reserved", 35 | length = 0 36 | ), 37 | attributes = listOf( 38 | Feed.Channel.Item.TorznabAttribute("category", "1"), 39 | Feed.Channel.Item.TorznabAttribute("seeders", "1"), 40 | Feed.Channel.Item.TorznabAttribute("peers", "1"), 41 | Feed.Channel.Item.TorznabAttribute("size", "152488462"), 42 | ) 43 | ) 44 | ) 45 | ) 46 | ) 47 | } 48 | 49 | }) 50 | -------------------------------------------------------------------------------- /app/src/test/kotlin/amarr/amule/DebugApiKtTest.kt: -------------------------------------------------------------------------------- 1 | package amarr.amule 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.ktor.client.request.* 6 | import io.ktor.serialization.kotlinx.json.* 7 | import io.ktor.server.application.* 8 | import io.ktor.server.plugins.contentnegotiation.* 9 | import io.ktor.server.testing.* 10 | import io.mockk.every 11 | import io.mockk.mockk 12 | import io.mockk.verify 13 | import jamule.AmuleClient 14 | import jamule.response.StatsResponse 15 | import kotlinx.serialization.json.Json 16 | 17 | class DebugApiKtTest : StringSpec({ 18 | val amuleClient = mockk() 19 | 20 | val sampleStatsResponse = StatsResponse( 21 | bannedCount = 0, 22 | buddyIp = null, 23 | buddyPort = null, 24 | buddyStatus = StatsResponse.BuddyState.Disconnected, 25 | connectionState = null, 26 | downloadOverhead = 0, 27 | downloadSpeed = 0, 28 | downloadSpeedLimit = 0, 29 | ed2kFiles = 0, 30 | ed2kUsers = 0, 31 | kadFiles = 0, 32 | kadFirewalledUdp = false, 33 | kadIndexedKeywords = 0, 34 | kadIndexedLoad = 0, 35 | kadIndexedNotes = 0, 36 | kadIndexedSources = 0, 37 | kadIpAddress = "192.168.3.1", 38 | kadIsRunningInLanMode = false, 39 | kadNodes = 0, 40 | kadUsers = 0, 41 | loggerMessage = emptyList(), 42 | sharedFileCount = 0, 43 | totalReceivedBytes = 0, 44 | totalSentBytes = 0, 45 | totalSourceCount = 0, 46 | uploadOverhead = 0, 47 | uploadQueueLength = 0, 48 | uploadSpeed = 0, 49 | uploadSpeedLimit = 0 50 | ) 51 | 52 | "should call amule client" { 53 | testApplication { 54 | application { 55 | debugApi(amuleClient) 56 | install(ContentNegotiation) { 57 | json(Json { 58 | ignoreUnknownKeys = true 59 | isLenient = true 60 | prettyPrint = true 61 | }) 62 | } 63 | } 64 | every { amuleClient.getStats() } returns Result.success(sampleStatsResponse) 65 | val response = client.get("/status") 66 | response.status.value shouldBe 200 67 | verify { amuleClient.getStats() } 68 | } 69 | } 70 | 71 | }) 72 | -------------------------------------------------------------------------------- /app/src/test/kotlin/amarr/torznab/TorznabApiTest.kt: -------------------------------------------------------------------------------- 1 | package amarr.torznab 2 | 3 | import amarr.torznab.indexer.AmuleIndexer 4 | import amarr.torznab.indexer.ddunlimitednet.DdunlimitednetIndexer 5 | import amarr.torznab.model.Caps 6 | import amarr.torznab.model.Feed 7 | import io.kotest.assertions.throwables.shouldThrow 8 | import io.kotest.core.spec.style.StringSpec 9 | import io.ktor.client.request.* 10 | import io.ktor.server.testing.* 11 | import io.mockk.coEvery 12 | import io.mockk.coVerify 13 | import io.mockk.mockk 14 | 15 | class TorznabApiTest : StringSpec({ 16 | val amuleIndexer = mockk() 17 | val ddunlimitednetIndexer = mockk() 18 | 19 | "should throw exception when missing action" { 20 | testApplication { 21 | application { 22 | torznabApi(amuleIndexer, ddunlimitednetIndexer) 23 | } 24 | shouldThrow { client.get("/api") } 25 | } 26 | } 27 | 28 | "should throw exception on unknown action" { 29 | testApplication { 30 | application { 31 | torznabApi(amuleIndexer, ddunlimitednetIndexer) 32 | } 33 | shouldThrow { client.get("/api?t=unknown") } 34 | } 35 | } 36 | 37 | "should get capabilities from amule indexer when called on /api" { 38 | testApplication { 39 | application { 40 | torznabApi(amuleIndexer, ddunlimitednetIndexer) 41 | } 42 | coEvery { amuleIndexer.capabilities() } returns Caps() 43 | client.get("/api?t=caps") 44 | coVerify { amuleIndexer.capabilities() } 45 | } 46 | } 47 | 48 | "should pass query, offset and limits to amule indexer when called on /api" { 49 | testApplication { 50 | application { 51 | torznabApi(amuleIndexer, ddunlimitednetIndexer) 52 | } 53 | coEvery { 54 | amuleIndexer.search( 55 | "test", 56 | 0, 57 | 100, 58 | listOf() 59 | ) 60 | } returns Feed( 61 | channel = Feed.Channel( 62 | response = Feed.Channel.Response(offset = 0, total = 0), 63 | item = emptyList() 64 | ) 65 | ) 66 | client.get("/api?t=search&q=test&offset=0&limit=100") 67 | coVerify { amuleIndexer.search("test", 0, 100, listOf()) } 68 | } 69 | } 70 | }) -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/torznab/model/Caps.kt: -------------------------------------------------------------------------------- 1 | package amarr.torznab.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | @SerialName("caps") 8 | data class Caps( 9 | val server: Server = Server(), 10 | val limits: Limits = Limits(), 11 | val searching: Searching = Searching(), 12 | val categories: Categories = Categories() 13 | ) { 14 | 15 | @Serializable 16 | @SerialName("server") 17 | data class Server(val version: String = "1.0", val title: String = "Amarr") 18 | 19 | @Serializable 20 | @SerialName("limits") 21 | data class Limits(val max: Int = 10000, val default: Int = 10000) 22 | 23 | @Serializable 24 | @SerialName("searching") 25 | data class Searching( 26 | val search: Search = Search(), 27 | val tvSearch: TvSearch = TvSearch(), 28 | val movieSearch: MovieSearch = MovieSearch(), 29 | val audioSearch: AudioSearch = AudioSearch(), 30 | val bookSearch: BookSearch = BookSearch() 31 | ) { 32 | @Serializable 33 | @SerialName("search") 34 | data class Search( 35 | val available: String = "yes", 36 | val supportedParams: String = "q", 37 | val searchEngine: String = "raw", 38 | ) 39 | 40 | @Serializable 41 | @SerialName("tv-search") 42 | data class TvSearch( 43 | val available: String = "yes", 44 | val supportedParams: String = "q,season,ep", 45 | val searchEngine: String = "raw", 46 | ) 47 | 48 | @Serializable 49 | @SerialName("movie-search") 50 | data class MovieSearch( 51 | val available: String = "no", 52 | val supportedParams: String = "q", 53 | val searchEngine: String = "raw", 54 | ) 55 | 56 | @Serializable 57 | @SerialName("audio-search") 58 | data class AudioSearch( 59 | val available: String = "no", 60 | val supportedParams: String = "q", 61 | val searchEngine: String = "raw", 62 | ) 63 | 64 | @Serializable 65 | @SerialName("book-search") 66 | data class BookSearch( 67 | val available: String = "no", 68 | val supportedParams: String = "q", 69 | val searchEngine: String = "raw", 70 | ) 71 | } 72 | 73 | @Serializable 74 | @SerialName("categories") 75 | class Categories( 76 | val category: List = listOf( 77 | Category( 78 | id = 1, 79 | name = "All", 80 | ) 81 | ) 82 | ) { 83 | @Serializable 84 | @SerialName("category") 85 | data class Category( 86 | val id: Int, 87 | val name: String, 88 | ) 89 | } 90 | 91 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/torznab/indexer/AmuleIndexer.kt: -------------------------------------------------------------------------------- 1 | package amarr.torznab.indexer 2 | 3 | import amarr.MagnetLink 4 | import amarr.torznab.model.Caps 5 | import amarr.torznab.model.Feed 6 | import amarr.torznab.model.Feed.Channel.Item 7 | import io.ktor.util.logging.* 8 | import jamule.AmuleClient 9 | import jamule.response.SearchResultsResponse.SearchFile 10 | 11 | class AmuleIndexer(private val amuleClient: AmuleClient, private val log: Logger) : Indexer { 12 | 13 | override suspend fun search(query: String, offset: Int, limit: Int, cat: List): Feed { 14 | log.debug("Starting search for query: {}, offset: {}, limit: {}", query, offset, limit) 15 | if (query.isBlank()) { 16 | log.debug("Empty query, returning empty response") 17 | return EMPTY_QUERY_RESPONSE 18 | } 19 | return buildFeed(amuleClient.searchSync(query).getOrThrow().files, offset, limit) 20 | } 21 | 22 | override suspend fun capabilities(): Caps = Caps() 23 | 24 | private fun buildFeed(items: List, offset: Int, limit: Int) = Feed( 25 | channel = Feed.Channel( 26 | response = Feed.Channel.Response( 27 | offset = offset, 28 | total = items.size 29 | ), 30 | item = items 31 | .drop(offset) 32 | .take(limit) 33 | .map { result -> 34 | Item( 35 | title = result.fileName, 36 | enclosure = Item.Enclosure( 37 | url = MagnetLink.forAmarr(result.hash, result.fileName, result.sizeFull).toString(), 38 | length = result.sizeFull 39 | ), 40 | attributes = listOf( 41 | Item.TorznabAttribute("category", "1"), 42 | Item.TorznabAttribute("seeders", result.completeSourceCount.toString()), 43 | Item.TorznabAttribute("peers", result.sourceCount.toString()), 44 | Item.TorznabAttribute("size", result.sizeFull.toString()) 45 | ) 46 | ) 47 | } 48 | ) 49 | ) 50 | 51 | companion object { 52 | private val EMPTY_QUERY_RESPONSE = Feed( 53 | channel = Feed.Channel( 54 | response = Feed.Channel.Response(offset = 0, total = 1), 55 | item = listOf( 56 | Item( 57 | title = "No query provided", 58 | enclosure = Item.Enclosure("http://mock.url", 0), 59 | attributes = listOf( 60 | Item.TorznabAttribute("category", "1"), 61 | Item.TorznabAttribute("size", "0") 62 | ) 63 | ) 64 | ) 65 | ) 66 | ) 67 | } 68 | 69 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/torznab/TorznabApi.kt: -------------------------------------------------------------------------------- 1 | package amarr.torznab 2 | 3 | import amarr.torznab.indexer.AmuleIndexer 4 | import amarr.torznab.indexer.Indexer 5 | import amarr.torznab.indexer.ThrottledException 6 | import amarr.torznab.indexer.UnauthorizedException 7 | import amarr.torznab.indexer.ddunlimitednet.DdunlimitednetIndexer 8 | import io.ktor.http.* 9 | import io.ktor.server.application.* 10 | import io.ktor.server.response.* 11 | import io.ktor.server.routing.* 12 | import kotlinx.serialization.encodeToString 13 | import nl.adaptivity.xmlutil.XmlDeclMode 14 | import nl.adaptivity.xmlutil.core.XmlVersion 15 | import nl.adaptivity.xmlutil.serialization.XML 16 | 17 | 18 | fun Application.torznabApi(amuleIndexer: AmuleIndexer, ddunlimitednetIndexer: DdunlimitednetIndexer) { 19 | routing { 20 | // Kept for legacy reasons 21 | get("/api") { 22 | call.handleRequests(amuleIndexer) 23 | } 24 | get("/indexer/amule/api") { 25 | call.handleRequests(amuleIndexer) 26 | } 27 | get("indexer/ddunlimitednet/api") { 28 | call.handleRequests(ddunlimitednetIndexer) 29 | } 30 | } 31 | } 32 | 33 | private suspend fun ApplicationCall.handleRequests(indexer: Indexer) { 34 | application.log.debug("Handling torznab request") 35 | val xmlFormat = XML { 36 | xmlDeclMode = XmlDeclMode.Charset 37 | xmlVersion = XmlVersion.XML10 38 | } // This API uses XML instead of JSON 39 | request.queryParameters["t"]?.let { 40 | when (it) { 41 | "caps" -> { 42 | application.log.debug("Handling caps request") 43 | respondText(xmlFormat.encodeToString(indexer.capabilities()), contentType = ContentType.Application.Xml) 44 | } 45 | 46 | "tvsearch" -> performSearch(indexer, xmlFormat) 47 | "search" -> performSearch(indexer, xmlFormat) 48 | 49 | else -> throw IllegalArgumentException("Unknown action: $it") 50 | } 51 | } ?: throw IllegalArgumentException("Missing action") 52 | } 53 | 54 | private suspend fun ApplicationCall.performSearch(indexer: Indexer, xmlFormat: XML) { 55 | val query = request.queryParameters["q"].orEmpty() 56 | val offset = request.queryParameters["offset"]?.toIntOrNull() ?: 0 57 | val limit = request.queryParameters["limit"]?.toIntOrNull() ?: 100 58 | val cat = request.queryParameters["cat"]?.split(",")?.map { cat -> cat.toInt() } ?: emptyList() 59 | application.log.debug("Handling search request: {}, {}, {}, {}", query, offset, limit, cat) 60 | try { 61 | respondText( 62 | xmlFormat.encodeToString(indexer.search(query, offset, limit, cat)), 63 | contentType = ContentType.Application.Xml 64 | ) 65 | } catch (e: ThrottledException) { 66 | application.log.warn("Throttled, returning 403") 67 | respondText("You are being throttled. Retry in a few minutes.", status = HttpStatusCode.Forbidden) 68 | } catch (e: UnauthorizedException) { 69 | application.log.warn("Unauthorized, returning 401") 70 | respondText("Unauthorized, check your credentials.", status = HttpStatusCode.Unauthorized) 71 | } 72 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /app/src/test/kotlin/amarr/torznab/indexer/AmuleIndexerTest.kt: -------------------------------------------------------------------------------- 1 | package amarr.torznab.indexer 2 | 3 | import amarr.MagnetLink 4 | import amarr.torznab.model.Feed.Channel.Item.TorznabAttribute 5 | import io.kotest.core.spec.style.StringSpec 6 | import io.kotest.matchers.collections.shouldContain 7 | import io.kotest.matchers.shouldBe 8 | import io.mockk.Called 9 | import io.mockk.every 10 | import io.mockk.mockk 11 | import io.mockk.verify 12 | import jamule.AmuleClient 13 | import jamule.response.SearchResultsResponse 14 | import jamule.response.SearchResultsResponse.SearchFile 15 | import org.slf4j.LoggerFactory 16 | 17 | class AmuleIndexerTest : StringSpec({ 18 | val mockClient = mockk() 19 | val logger = LoggerFactory.getLogger(AmuleIndexerTest::class.java) 20 | 21 | "should return single category in capabilities" { 22 | val indexer = AmuleIndexer(mockClient, logger) 23 | val capabilities = indexer.capabilities() 24 | capabilities.categories.category.size shouldBe 1 25 | capabilities.categories.category[0].name shouldBe "All" 26 | capabilities.categories.category[0].id shouldBe 1 27 | } 28 | 29 | "when empty queried should return only one result within that category" { 30 | val indexer = AmuleIndexer(mockClient, logger) 31 | val results = indexer.search("", 0, 1000, listOf()) 32 | results.channel.response.total shouldBe 1 33 | results.channel.response.offset shouldBe 0 34 | results.channel.item.size shouldBe 1 35 | val item = results.channel.item[0] 36 | item.title shouldBe "No query provided" 37 | item.enclosure.url shouldBe "http://mock.url" 38 | item.enclosure.length shouldBe 0 39 | item.attributes.size shouldBe 2 40 | item.attributes[0].name shouldBe "category" 41 | item.attributes[0].value shouldBe "1" 42 | item.attributes[1].name shouldBe "size" 43 | item.attributes[1].value shouldBe "0" 44 | verify { mockClient wasNot Called } 45 | } 46 | 47 | "when queried calls amule client" { 48 | val searchFile = SearchFile( 49 | fileName = "test", 50 | hash = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15), 51 | sizeFull = 1000, 52 | completeSourceCount = 1, 53 | sourceCount = 2, 54 | downloadStatus = SearchResultsResponse.SearchFileDownloadStatus.NEW, 55 | ) 56 | every { mockClient.searchSync(any()) } returns Result.success(SearchResultsResponse(listOf(searchFile))) 57 | val indexer = AmuleIndexer(mockClient, logger) 58 | val result = indexer.search("test", 0, 1000, listOf()) 59 | verify { mockClient.searchSync("test") } 60 | result.channel.response.total shouldBe 1 61 | result.channel.response.offset shouldBe 0 62 | result.channel.item.size shouldBe 1 63 | val item = result.channel.item[0] 64 | item.title shouldBe "test" 65 | item.enclosure.url shouldBe MagnetLink.forAmarr(searchFile.hash, "test", searchFile.sizeFull).toString() 66 | item.enclosure.length shouldBe 1000 67 | item.attributes.size shouldBe 4 68 | item.attributes shouldContain TorznabAttribute("category", "1") 69 | item.attributes shouldContain TorznabAttribute("size", "1000") 70 | item.attributes shouldContain TorznabAttribute("seeders", "1") 71 | item.attributes shouldContain TorznabAttribute("peers", "2") 72 | } 73 | 74 | }) 75 | -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/App.kt: -------------------------------------------------------------------------------- 1 | package amarr 2 | 3 | import amarr.amule.debugApi 4 | import amarr.category.FileCategoryStore 5 | import amarr.torrent.torrentApi 6 | import amarr.torznab.indexer.AmuleIndexer 7 | import amarr.torznab.indexer.ddunlimitednet.DdunlimitednetClient 8 | import amarr.torznab.indexer.ddunlimitednet.DdunlimitednetIndexer 9 | import amarr.torznab.torznabApi 10 | import io.ktor.client.engine.cio.* 11 | import io.ktor.serialization.kotlinx.json.* 12 | import io.ktor.server.application.* 13 | import io.ktor.server.engine.* 14 | import io.ktor.server.netty.* 15 | import io.ktor.server.plugins.callloging.* 16 | import io.ktor.server.plugins.contentnegotiation.* 17 | import jamule.AmuleClient 18 | import kotlinx.serialization.json.Json 19 | import org.jetbrains.annotations.VisibleForTesting 20 | import org.slf4j.Logger 21 | import org.slf4j.event.Level 22 | 23 | private val AMULE_PORT = System.getenv("AMULE_PORT").apply { 24 | if (this == null) throw Exception("AMULE_PORT is not set") 25 | } 26 | private val AMULE_HOST = System.getenv("AMULE_HOST").apply { 27 | if (this == null) throw Exception("AMULE_HOST is not set") 28 | } 29 | private val AMULE_PASSWORD = System.getenv("AMULE_PASSWORD").apply { 30 | if (this == null) throw Exception("AMULE_PASSWORD is not set") 31 | } 32 | private val AMULE_FINISHED_PATH = System.getenv("AMULE_FINISHED_PATH").let { it ?: "/finished" } 33 | private val AMARR_CONFIG_PATH = System.getenv("AMARR_CONFIG_PATH").let { it ?: "/config" } 34 | private val AMARR_LOG_LEVEL = System.getenv("AMARR_LOG_LEVEL").let { it ?: "INFO" } 35 | private val DDUNLIMITEDNET_USERNAME = System.getenv("DDUNLIMITEDNET_USERNAME") 36 | private val DDUNLIMITEDNET_PASSWORD = System.getenv("DDUNLIMITEDNET_PASSWORD") 37 | 38 | fun main() { 39 | embeddedServer( 40 | Netty, port = 8080 41 | ) { 42 | app() 43 | }.start(wait = true) 44 | } 45 | 46 | @VisibleForTesting 47 | internal fun Application.app() { 48 | setLogLevel(log) 49 | val amuleClient = buildClient(log) 50 | val amuleIndexer = AmuleIndexer(amuleClient, log) 51 | val ddunlimitednetClient = DdunlimitednetClient(CIO.create(), DDUNLIMITEDNET_USERNAME, DDUNLIMITEDNET_PASSWORD, log) 52 | val ddunlimitednetIndexer = DdunlimitednetIndexer(ddunlimitednetClient, log) 53 | val categoryStore = FileCategoryStore(AMARR_CONFIG_PATH) 54 | 55 | install(CallLogging) { 56 | level = Level.DEBUG 57 | } 58 | install(ContentNegotiation) { 59 | json(Json { 60 | ignoreUnknownKeys = true 61 | isLenient = true 62 | prettyPrint = true 63 | encodeDefaults = true 64 | }) 65 | } 66 | debugApi(amuleClient) 67 | torznabApi(amuleIndexer, ddunlimitednetIndexer) 68 | torrentApi(amuleClient, categoryStore, AMULE_FINISHED_PATH) 69 | } 70 | 71 | private fun setLogLevel(logger: Logger) { 72 | val logBackLogger = logger as ch.qos.logback.classic.Logger 73 | when (AMARR_LOG_LEVEL) { 74 | "DEBUG" -> logBackLogger.level = ch.qos.logback.classic.Level.DEBUG 75 | "INFO" -> logBackLogger.level = ch.qos.logback.classic.Level.INFO 76 | "WARN" -> logBackLogger.level = ch.qos.logback.classic.Level.WARN 77 | "ERROR" -> logBackLogger.level = ch.qos.logback.classic.Level.ERROR 78 | else -> throw Exception("Unknown log level: $AMARR_LOG_LEVEL") 79 | } 80 | } 81 | 82 | fun buildClient(logger: Logger): AmuleClient = 83 | AmuleClient(AMULE_HOST, AMULE_PORT.toInt(), AMULE_PASSWORD, logger = logger) 84 | 85 | -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/category/FileCategoryStore.kt: -------------------------------------------------------------------------------- 1 | package amarr.category 2 | 3 | import amarr.torrent.model.Category 4 | import java.io.File 5 | 6 | /** 7 | * Stores the relation category - file hash in a file in the amule config directory. 8 | * The access to this file is synchronized. 9 | */ 10 | class FileCategoryStore(storePath: String) : CategoryStore { 11 | private val hashesCache: MutableMap = mutableMapOf() 12 | private var categoriesCache: MutableSet? = null 13 | private var categoriesFilePath = File(storePath, CATEGORIES_FILE).absolutePath 14 | private var hashesFilePath = File(storePath, HASHES_FILE).absolutePath 15 | 16 | override fun store(category: String, hash: String) { 17 | synchronized(HASHES_FILE) { 18 | if (category.contains('\t') || hash.contains('\t')) 19 | throw IllegalArgumentException("Category or hash contains tab character") 20 | val file = File(hashesFilePath) 21 | if (!file.exists()) { 22 | file.parentFile.mkdirs() 23 | file.createNewFile() 24 | } 25 | file.appendText("$hash\t$category\n") 26 | hashesCache[hash] = category 27 | } 28 | } 29 | 30 | override fun getCategory(hash: String): String? { 31 | synchronized(HASHES_FILE) { 32 | if (hashesCache.containsKey(hash)) 33 | return hashesCache[hash] 34 | val file = File(hashesFilePath) 35 | if (!file.exists()) 36 | return null 37 | val line = file.readLines().find { it.split('\t')[0] == hash } ?: return null 38 | val category = line.split('\t')[1] 39 | hashesCache[hash] = category 40 | return category 41 | } 42 | } 43 | 44 | override fun delete(hash: String) { 45 | synchronized(HASHES_FILE) { 46 | val file = File(hashesFilePath) 47 | if (!file.exists()) 48 | return 49 | val lines = file.readLines() 50 | val line = lines.find { it.split('\t')[0] == hash } ?: return 51 | file.writeText(lines.filterNot { it == line }.joinToString("\n")) 52 | hashesCache.remove(hash) 53 | } 54 | } 55 | 56 | override fun addCategory(category: Category) { 57 | synchronized(CATEGORIES_FILE) { 58 | if (categoriesCache != null) 59 | categoriesCache!!.add(category) 60 | val file = File(categoriesFilePath) 61 | if (!file.exists()) { 62 | file.parentFile.mkdirs() 63 | file.createNewFile() 64 | } 65 | file.appendText("${category.name}\t${category.savePath}\n") 66 | } 67 | } 68 | 69 | override fun getCategories(): Set { 70 | synchronized(CATEGORIES_FILE) { 71 | if (categoriesCache != null) 72 | return categoriesCache!! 73 | val file = File(categoriesFilePath) 74 | if (!file.exists()) 75 | return emptySet() 76 | val categories = file.readLines().map { line -> 77 | val split = line.split('\t') 78 | Category(split[0], split[1]) 79 | } 80 | categoriesCache = categories.toMutableSet() 81 | return categoriesCache!! 82 | } 83 | } 84 | 85 | companion object { 86 | private const val CATEGORIES_FILE = "categories.tsv" 87 | private const val HASHES_FILE = "hashes.tsv" 88 | } 89 | 90 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/MagnetLink.kt: -------------------------------------------------------------------------------- 1 | package amarr 2 | 3 | import com.google.common.io.BaseEncoding.base32 4 | import io.ktor.http.* 5 | 6 | data class MagnetLink( 7 | private val hash: ByteArray, 8 | val name: String, 9 | val size: Long, 10 | val trackers: List, 11 | ) { 12 | fun toEd2kLink(): String { 13 | return "ed2k://|file|${name.encodeURLParameter()}|$size|${amuleHexHash()}|/" 14 | } 15 | 16 | @OptIn(ExperimentalStdlibApi::class) 17 | fun amuleHexHash(): String { 18 | // unpad the hash to ensure a size of 128 bits, then encode it as hex 19 | return hash.copyOf(16).toHexString() 20 | } 21 | 22 | fun isAmarr(): Boolean { 23 | return trackers.contains(AMARR_TRACKER) 24 | } 25 | 26 | override fun toString(): String { 27 | // pad the hash to ensure a size of 160 bits 28 | val hash = hash.copyOf(20) 29 | val base32Hash = base32().encode(hash) 30 | return "magnet:" + 31 | "?xt=urn:btih:$base32Hash" + 32 | "&dn=${name.encodeURLParameter()}" + 33 | "&xl=$size" + 34 | "&tr=${trackers.joinToString("&tr=") { it.encodeURLParameter() }}" 35 | } 36 | 37 | override fun equals(other: Any?): Boolean { 38 | if (this === other) return true 39 | if (javaClass != other?.javaClass) return false 40 | 41 | other as MagnetLink 42 | 43 | if (!hash.contentEquals(other.hash)) return false 44 | if (name != other.name) return false 45 | if (size != other.size) return false 46 | if (trackers != other.trackers) return false 47 | 48 | return true 49 | } 50 | 51 | override fun hashCode(): Int { 52 | var result = hash.contentHashCode() 53 | result = 31 * result + name.hashCode() 54 | result = 31 * result + size.hashCode() 55 | result = 31 * result + trackers.hashCode() 56 | return result 57 | } 58 | 59 | companion object { 60 | fun forAmarr(hash: ByteArray, name: String, size: Long) = MagnetLink( 61 | hash = hash, 62 | name = name, 63 | size = size, 64 | trackers = listOf(AMARR_TRACKER) 65 | ) 66 | 67 | fun fromString(magnet: String): MagnetLink = magnet 68 | .substringAfter("magnet:?") 69 | .split("&") 70 | .filter { it.matches(Regex(".+=.+")) } 71 | .map { val els = it.split("="); els[0] to els[1] } 72 | .let { params -> 73 | val hash = base32().decode(params.first { it.first == "xt" }.second.substringAfter("urn:btih:")) 74 | MagnetLink( 75 | hash = hash, 76 | name = params.first { it.first == "dn" }.second.decodeURLPart(), 77 | size = params.first { it.first == "xl" }.second.toLong(), 78 | trackers = params.filter { it.first == "tr" }.map { it.second.decodeURLPart() } 79 | ) 80 | } 81 | 82 | @OptIn(ExperimentalStdlibApi::class) 83 | fun fromEd2k(ed2k: String): MagnetLink = ed2k 84 | .substringAfter("ed2k://|file|") 85 | .substringBefore("|/") 86 | .split("|") 87 | .let { els -> 88 | MagnetLink( 89 | hash = els[2].hexToByteArray(), 90 | name = els[0].decodeURLPart(), 91 | size = els[1].toLong(), 92 | trackers = listOf(AMARR_TRACKER) 93 | ) 94 | } 95 | 96 | const val AMARR_TRACKER = "http://amarr-reserved" 97 | } 98 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/torznab/indexer/ddunlimitednet/DdunlimitednetIndexer.kt: -------------------------------------------------------------------------------- 1 | package amarr.torznab.indexer.ddunlimitednet 2 | 3 | import amarr.MagnetLink 4 | import amarr.torznab.indexer.Indexer 5 | import amarr.torznab.indexer.ThrottledException 6 | import amarr.torznab.indexer.UnauthorizedException 7 | import amarr.torznab.model.Caps 8 | import amarr.torznab.model.Feed 9 | import org.slf4j.Logger 10 | 11 | class DdunlimitednetIndexer( 12 | private val client: DdunlimitednetClient, 13 | private val log: Logger 14 | ) : Indexer { 15 | 16 | override suspend fun search(query: String, offset: Int, limit: Int, cat: List): Feed { 17 | log.info("Searching for query: `{}` in categories: `{}`", query, cat) 18 | // Here "tutto" is simply a word that will match something in all supported categories 19 | val cleanQuery = query.ifEmpty { "tutto" } 20 | val result = client.search(cleanQuery, cat).recover { error -> 21 | when (error) { 22 | is UnauthorizedException -> { 23 | log.info("Unauthorized, logging in...") 24 | client.login() 25 | client.search(cleanQuery, cat).getOrThrow() 26 | } 27 | 28 | is ThrottledException -> { 29 | log.info("Throttled, returning 403...") 30 | throw error 31 | } 32 | 33 | else -> throw error 34 | } 35 | } 36 | return linksToFeed(result.getOrThrow(), cat) 37 | } 38 | 39 | // TODO: Pagination 40 | override suspend fun capabilities(): Caps = Caps( 41 | categories = Caps.Categories( 42 | category = listOf( 43 | Caps.Categories.Category( 44 | id = 1577, 45 | name = "SerieTV", 46 | ), 47 | Caps.Categories.Category( 48 | id = 1572, 49 | name = "Movies", 50 | ) 51 | ) 52 | ) 53 | ) 54 | 55 | private fun linksToFeed(links: List, cat: List): Feed { 56 | log.info("Found {} links", links.size) 57 | log.trace("Links: {}", links) 58 | return Feed( 59 | channel = Feed.Channel( 60 | response = Feed.Channel.Response( 61 | offset = 0, 62 | total = links.size 63 | ), 64 | item = links 65 | .mapNotNull { link -> runCatching { MagnetLink.fromEd2k(link) }.getOrNull() } 66 | .mapIndexed { idx, link -> 67 | // TODO: This is horrible, but we do not yet have the categories implemented in the scraper. 68 | val currentCategory = if (cat.isEmpty()) "1" else cat[idx % cat.size].toString() 69 | Feed.Channel.Item( 70 | title = link.name, 71 | enclosure = Feed.Channel.Item.Enclosure( 72 | url = link.toString(), 73 | length = 0 74 | ), 75 | attributes = listOf( 76 | Feed.Channel.Item.TorznabAttribute("category", currentCategory), 77 | Feed.Channel.Item.TorznabAttribute("seeders", "1"), 78 | Feed.Channel.Item.TorznabAttribute("peers", "1"), 79 | Feed.Channel.Item.TorznabAttribute("size", link.size.toString()) 80 | ) 81 | ) 82 | } 83 | ) 84 | ) 85 | } 86 | 87 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/torrent/TorrentApi.kt: -------------------------------------------------------------------------------- 1 | package amarr.torrent 2 | 3 | import amarr.category.CategoryStore 4 | import amarr.torrent.model.Category 5 | import amarr.torrent.model.Preferences 6 | import io.ktor.server.application.* 7 | import io.ktor.server.request.* 8 | import io.ktor.server.response.* 9 | import io.ktor.server.routing.* 10 | import jamule.AmuleClient 11 | 12 | fun Application.torrentApi(amuleClient: AmuleClient, categoryStore: CategoryStore, finishedPath: String) { 13 | val service = TorrentService(amuleClient, categoryStore, finishedPath, log) 14 | routing { 15 | get("/api/v2/app/webapiVersion") { 16 | call.respondText("2.8.19") // Emulating qBittorrent API version 2.8.19 17 | } 18 | post("/api/v2/auth/login") { 19 | val params = call.receiveParameters() 20 | val username = params["username"] 21 | val password = params["password"] 22 | // TODO: Implement some kind of authentication 23 | call.respondText("Ok.") 24 | } 25 | get("/api/v2/app/preferences") { 26 | call.respond(Preferences(save_path = finishedPath)) 27 | } 28 | post("/api/v2/torrents/add") { 29 | val params = call.receiveParameters() 30 | val urls = params["urls"]?.split("\n")?.filterNot { it.isBlank() } 31 | val category = params["category"] 32 | val paused = params["paused"] 33 | call.application.log.debug( 34 | "Received add torrent request with urls: {}, category: {}, paused: {}", 35 | urls, 36 | category, 37 | paused 38 | ) 39 | service.addTorrent(urls, category, paused) 40 | call.respondText("Ok.") 41 | } 42 | post("/api/v2/torrents/createCategory") { 43 | val params = call.receiveParameters() 44 | val category = Category(params["category"]!!, params["savePath"] ?: "") 45 | call.application.log.debug("Received create category request with category: {}", category) 46 | service.addCategory(category) 47 | call.respondText("Ok.") 48 | } 49 | get("/api/v2/torrents/categories") { 50 | call.respond(service.getCategories()) 51 | } 52 | get("/api/v2/torrents/info") { 53 | val category = call.request.queryParameters["category"] 54 | call.respond(service.getTorrentInfo(category)) 55 | } 56 | post("/api/v2/torrents/delete") { 57 | val params = call.receiveParameters() 58 | val hashes = params["hashes"]!!.split("|") 59 | val deleteFiles = params["deleteFiles"] 60 | call.application.log.debug( 61 | "Received delete torrent request with hashes: {}, deleteFiles: {}", 62 | hashes, 63 | deleteFiles 64 | ) 65 | if (hashes.size == 1 && hashes[0] == "all") 66 | service.deleteAllTorrents(deleteFiles) 67 | else service.deleteTorrent(hashes, deleteFiles) 68 | call.respondText("Ok.") 69 | } 70 | get("/api/v2/torrents/files") { 71 | val hash = call.request.queryParameters["hash"]!! 72 | call.application.log.debug("Received get files request with hash: {}", hash) 73 | val response = listOf(service.getFile(hash)) 74 | call.respond(response) 75 | } 76 | get("/api/v2/torrents/properties") { 77 | val hash = call.request.queryParameters["hash"]!! 78 | call.application.log.debug("Received get properties request with hash: {}", hash) 79 | val response = service.getTorrentProperties(hash) 80 | call.respond(response) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | // Apply the foojay-resolver plugin to allow automatic download of JDKs 3 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.7.0" 4 | } 5 | 6 | rootProject.name = "amarr" 7 | 8 | include("app") 9 | 10 | dependencyResolutionManagement { 11 | versionCatalogs { 12 | create("libs") { 13 | // Versions 14 | version("ktor", "2.3.7") 15 | version("kotlin", "1.9.21") 16 | version("bt", "1.10") 17 | version("kotest", "5.8.0") 18 | 19 | // Libraries 20 | library("ktor-server-core", "io.ktor", "ktor-server-core").versionRef("ktor") 21 | library("ktor-server-netty", "io.ktor", "ktor-server-netty").versionRef("ktor") 22 | library("ktor-server-content-negotiation", "io.ktor", "ktor-server-content-negotiation").versionRef("ktor") 23 | library("ktor-server-call-logging", "io.ktor", "ktor-server-call-logging").versionRef("ktor") 24 | library("ktor-server-call-logging-jvm", "io.ktor", "ktor-server-call-logging-jvm").versionRef("ktor") 25 | library("ktor-server-default-headers-jvm", "io.ktor", "ktor-server-default-headers-jvm").versionRef("ktor") 26 | library("ktor-serialization-kotlinx-xml", "io.ktor", "ktor-serialization-kotlinx-xml").versionRef("ktor") 27 | library("ktor-serialization-kotlinx-json", "io.ktor", "ktor-serialization-kotlinx-json").versionRef("ktor") 28 | library("ktor-server-test-host-jvm", "io.ktor", "ktor-server-test-host-jvm").versionRef("ktor") 29 | library("ktor-client-core", "io.ktor", "ktor-client-core").versionRef("ktor") 30 | library("ktor-client-cio", "io.ktor", "ktor-client-cio").versionRef("ktor") 31 | library("ktor-client-logging", "io.ktor", "ktor-client-logging").versionRef("ktor") 32 | library("ktor-client-mock", "io.ktor", "ktor-client-mock").versionRef("ktor") 33 | library("jamule", "com.vexdev", "jamule").version("1.0.3") 34 | library("guava", "com.google.guava", "guava").version("32.1.3-jre") 35 | library("kotest-runner-junit5", "io.kotest", "kotest-runner-junit5").versionRef("kotest") 36 | library("kotest-assertions-core", "io.kotest", "kotest-assertions-core").versionRef("kotest") 37 | library("kotest-property", "io.kotest", "kotest-property").versionRef("kotest") 38 | library("mockk", "io.mockk", "mockk").version("1.13.8") 39 | library("logback", "ch.qos.logback", "logback-classic").version("1.4.14") 40 | library("kotlin-test-junit", "org.jetbrains.kotlin", "kotlin-test-junit").versionRef("kotlin") 41 | library("commons-text", "org.apache.commons", "commons-text").version("1.11.0") 42 | 43 | // Plugins 44 | plugin("kotlin-jvm", "org.jetbrains.kotlin.jvm").versionRef("kotlin") 45 | plugin("kotlin-serialization", "org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin") 46 | plugin("jib", "com.google.cloud.tools.jib").version("3.4.0") 47 | 48 | // Bundles 49 | bundle("kotest", listOf("kotest-runner-junit5", "kotest-assertions-core", "kotest-property")) 50 | bundle( 51 | "ktor-server", 52 | listOf( 53 | "ktor-server-core", 54 | "ktor-server-netty", 55 | "ktor-server-content-negotiation", 56 | "ktor-server-call-logging", 57 | "ktor-server-call-logging-jvm", 58 | "ktor-server-default-headers-jvm", 59 | "ktor-serialization-kotlinx-xml", 60 | "ktor-serialization-kotlinx-json" 61 | ) 62 | ) 63 | bundle( 64 | "ktor-client", 65 | listOf( 66 | "ktor-client-core", 67 | "ktor-client-cio", 68 | "ktor-client-logging" 69 | ) 70 | ) 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/torznab/indexer/ddunlimitednet/DdunlimitednetClient.kt: -------------------------------------------------------------------------------- 1 | package amarr.torznab.indexer.ddunlimitednet 2 | 3 | import amarr.torznab.indexer.ThrottledException 4 | import amarr.torznab.indexer.UnauthorizedException 5 | import io.ktor.client.* 6 | import io.ktor.client.call.* 7 | import io.ktor.client.engine.* 8 | import io.ktor.client.plugins.cookies.* 9 | import io.ktor.client.plugins.logging.* 10 | import io.ktor.client.request.forms.* 11 | import io.ktor.http.* 12 | import org.apache.commons.text.StringEscapeUtils 13 | import org.slf4j.Logger 14 | import java.util.regex.Pattern 15 | 16 | class DdunlimitednetClient( 17 | engine: HttpClientEngine, 18 | private val username: String?, 19 | private val password: String?, 20 | private val log: Logger 21 | ) { 22 | private val ed2kPattern = Pattern.compile("ed2k://\\|file\\|.*?\\|/") 23 | private val httpClient = HttpClient(engine) { 24 | install(HttpCookies) 25 | install(Logging) { 26 | level = LogLevel.INFO 27 | } 28 | } 29 | 30 | /** 31 | * Tries to search for a query, returning a list of ed2k links if successful. 32 | */ 33 | suspend fun search(query: String, cat: List): Result> = httpClient.prepareForm( 34 | formParameters = Parameters.build { 35 | append( 36 | "keywords", 37 | query 38 | ) 39 | append("terms", "any") 40 | append("gsearch", "0") 41 | append("author", "") 42 | append("sv", "0") 43 | for (category in cat) { 44 | append("fid%5B%5D", category.toString()) 45 | } 46 | append("sc", "1") 47 | append("sf", "titleonly") 48 | append("sr", "posts") 49 | append("sk", "t") 50 | append("sd", "d") 51 | append("st", "0") 52 | append("ch", "-1") 53 | append("t", "0") 54 | append("submit", "Search") 55 | }, 56 | url = "${URL_BASE}/search.php", 57 | ).execute { response -> 58 | val links = mutableListOf() 59 | val body: String = response.body() 60 | // TODO: Implement pagination 61 | for (line in body.lineSequence()) { 62 | val lineWithoutTags = line.replace("<[^>]*>".toRegex(), "") 63 | if (lineWithoutTags.contains(NOT_LOGGED)) 64 | return@execute Result.failure(UnauthorizedException()) 65 | if (lineWithoutTags.contains(THROTTLED)) 66 | return@execute Result.failure(ThrottledException()) 67 | val ed2kMatcher = ed2kPattern.matcher(lineWithoutTags) 68 | while (ed2kMatcher.find()) { 69 | val ed2kLink = ed2kMatcher.group() 70 | links.add(StringEscapeUtils.unescapeHtml4(ed2kLink)) 71 | } 72 | } 73 | Result.success(links) 74 | } 75 | 76 | suspend fun login() { 77 | if (username == null || password == null) { 78 | throw Exception("Username or password not set for ddunlimitednet indexer") 79 | } 80 | httpClient.submitForm( 81 | formParameters = Parameters.build { 82 | append("username", username) 83 | append("password", password) 84 | append("autologin", "on") 85 | append("redirect", "index.php") 86 | append("login", "Login") 87 | }, url = "${URL_BASE}/ucp.php", block = { 88 | parameters { append("mode", "login") } 89 | } 90 | ) 91 | } 92 | 93 | companion object { 94 | private val NOT_LOGGED = "Non ti è permesso di utilizzare il sistema di ricerca" 95 | private val THROTTLED = "Sorry but you cannot use search at this time. Please try again in a few minutes." 96 | private val LOGGED_PATTERN = "Logout [ %s ]" 97 | private val URL_BASE = "https://ddunlimited.net" 98 | private val SID_COOKIE = "phpbb3_ddu4final_sid" 99 | } 100 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amarr - aMule *arr Connector 2 | ![Docker Image Version (latest semver)](https://img.shields.io/docker/v/vexdev/amarr) 3 | ![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/vexdev/amarr/release.yml) 4 | [![semantic-release: angular](https://img.shields.io/badge/semantic--release-angular-e10079?logo=semantic-release)](https://github.com/semantic-release/semantic-release) 5 | 6 | 7 | This connector allows using aMule as a download client for [Sonarr](https://sonarr.tv/) 8 | and [Radarr](https://radarr.video/). 9 | It works by emulating a torrent client, so Sonarr and Radarr will manage your downloads as if they were torrents. 10 | 11 | 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**. 12 | 13 | Amarr has been especially tested with the latest released version of [Adunanza](https://www.adunanza.net/). 14 | 15 | ## Pre-requisites 16 | 17 | - [aMule](https://www.amule.org/) version **2.3.1** to **2.3.3** running and configured 18 | - [Sonarr](https://sonarr.tv/) or [Radarr](https://radarr.video/) running and configured 19 | 20 | **Amarr does not come with its own amule installation**, you need to have it running and configured. 21 | One way to do this is by using the [Amule Docker image from ngosang](https://github.com/ngosang/docker-amule). 22 | Or the [Adunanza Docker image from m4dfry](https://github.com/m4dfry/amule-adunanza-docker). 23 | Or again you could run aMule in a VM or in a physical machine. 24 | 25 | ## Installation 26 | 27 | Amarr runs as a docker container. You can find it in [Docker Hub](https://hub.docker.com/r/vexdev/amarr). 28 | 29 | It requires the following environment variables: 30 | 31 | ``` 32 | AMULE_HOST: aMule # The host where aMule is running, for docker containers it's usually the name of the container 33 | AMULE_PORT: 4712 # The port where aMule is listening with the EC protocol 34 | AMULE_PASSWORD: secret # The password to connect to aMule 35 | 36 | Optional parameters: 37 | AMULE_FINISHED_PATH: /finished # The directory where aMule will download the finished files 38 | AMARR_LOG_LEVEL: INFO # The log level of amarr, defaults to INFO 39 | ``` 40 | 41 | It also requires mounting the following volumes: 42 | 43 | ``` 44 | /config # The directory where amarr will store its configuration, must be persistent 45 | ``` 46 | 47 | The container exposes the port 8080, which is the port where amarr will expose the Torznab server for Sonarr/Radarr. 48 | 49 | ### Example docker-compose.yml 50 | 51 | ```yaml 52 | services: 53 | amarr: 54 | image: vexdev/amarr:latest 55 | container_name: amarr 56 | environment: 57 | - AMULE_HOST=aMule 58 | - AMULE_PORT=4712 59 | - AMULE_PASSWORD=secret 60 | volumes: 61 | - /path/to/amarr/config:/config 62 | ports: 63 | - 8080:8080 64 | ``` 65 | 66 | ## Radarr/Sonarr configuration (2 easy steps) 67 | 68 | ### 1. Configure amarr as a download client 69 | 70 | You will need to add the download client. 71 | 72 | You can do that by adding a new download client of type **qBittorrent** with the following settings: 73 | 74 | ``` 75 | ! Ensure you pressed the "Show advanced settings" button 76 | Name: Any name you want 77 | Host: amarr # The host where amarr is running, for docker containers it's usually the name of the container 78 | Port: 8080 # The port where amarr is listening 79 | Priority: 50 # This is the lowest possible priority, so Sonarr/Radarr will prefer other download clients 80 | ``` 81 | 82 | ### 2. Configure amarr as a torrent indexer 83 | 84 | Amarr provides multiple indexers with different capabilities. 85 | Each indexer implements the **Torznab** protocol, so it can be used as a torrent indexer for Sonarr/Radarr. 86 | 87 | Add a new **Torznab indexer** with the following settings: 88 | 89 | ``` 90 | ! Ensure you pressed the "Show advanced settings" button 91 | Name: Any name you want 92 | Url: http://amarr:8080/indexer/ 93 | Download Client: The name you gave to amarr in the previous step 94 | ``` 95 | 96 | **Note:** You need to configure Sonarr/Radarr to prefer amarr as a download client for any indexers we created before. 97 | 98 | **Note:** `` is [one of the indexers supported by amarr](#indexers). 99 | 100 | **You will have to do this for every indexer you want to use with amarr.** 101 | 102 | ## Indexers 103 | 104 | ### `amule` 105 | 106 | This indexer will search for files in aMule through the kad/eD2k network. 107 | 108 | It is very slow and not very reliable. Additionally, files on the kad/eD2k network are not well reviewed, so you may end 109 | up downloading fake files. 110 | 111 | Does not require any additional configuration. 112 | 113 | ### `ddunlimitednet` - BETA!! 114 | 115 | _⚠️⚠️⚠️ This indexer is still in beta. It may not work as expected. ⚠️⚠️⚠️_ 116 | 117 | ddunlimited.net is very restrictive with the number of searches you can perform, so this indexer is subject to rate limits. 118 | 119 | This indexer will search for files in [ddunlimited.net](https://ddunlimited.net/). 120 | 121 | Requires the following environment variables to be set: 122 | 123 | ``` 124 | DDUNLIMITEDNET_USERNAME: username # The username to connect to ddunlimited.net 125 | DDUNLIMITEDNET_PASSWORD: password # The password to connect to ddunlimited.net 126 | ``` 127 | -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/amule/DebugApi.kt: -------------------------------------------------------------------------------- 1 | package amarr.amule 2 | 3 | import io.ktor.server.application.* 4 | import io.ktor.server.response.* 5 | import io.ktor.server.routing.* 6 | import jamule.AmuleClient 7 | import jamule.response.StatsResponse 8 | import kotlinx.serialization.Serializable 9 | 10 | fun Application.debugApi(client: AmuleClient) { 11 | routing { 12 | get("/status") { 13 | call.respond(client.getStats().getOrThrow().let { StatusResponse.fromStatsResponse(it) }) 14 | } 15 | } 16 | } 17 | 18 | @Serializable 19 | data class StatusResponse( 20 | val bannedCount: Long = 0, 21 | val buddyIp: String? = null, 22 | val buddyPort: Short? = null, 23 | val buddyStatus: String? = null, 24 | val connectionStatus: ConnectionStatus? = ConnectionStatus(), 25 | val downloadOverhead: Long = 0, 26 | val downloadSpeed: Long = 0, 27 | val downloadSpeedLimit: Long = 0, 28 | val ed2kFiles: Long = 0, 29 | val ed2kUsers: Long = 0, 30 | val kadFiles: Long = 0, 31 | val kadFirewalledUdp: Boolean? = null, 32 | val kadIndexedKeywords: Long? = null, 33 | val kadIndexedLoad: Long? = null, 34 | val kadIndexedNotes: Long? = null, 35 | val kadIndexedSources: Long? = null, 36 | val kadIpAddress: String? = null, 37 | val kadIsRunningInLanMode: Boolean? = null, 38 | val kadNodes: Long = 0, 39 | val kadUsers: Long = 0, 40 | val loggerMessage: List = emptyList(), 41 | val sharedFileCount: Long = 0, 42 | val totalReceivedBytes: Long = 0, 43 | val totalSentBytes: Long = 0, 44 | val totalSourceCount: Long = 0, 45 | val uploadOverhead: Long = 0, 46 | val uploadQueueLength: Long = 0, 47 | val uploadSpeed: Long = 0, 48 | val uploadSpeedLimit: Long = 0 49 | ) { 50 | companion object { 51 | fun fromStatsResponse(statsResponse: StatsResponse) = 52 | StatusResponse( 53 | bannedCount = statsResponse.bannedCount, 54 | buddyIp = statsResponse.buddyIp, 55 | buddyPort = statsResponse.buddyPort?.toShort(), 56 | buddyStatus = statsResponse.buddyStatus?.name, 57 | connectionStatus = ConnectionStatus( 58 | clientId = statsResponse.connectionState?.clientId, 59 | ed2kConnected = statsResponse.connectionState?.ed2kConnected, 60 | ed2kConnecting = statsResponse.connectionState?.ed2kConnecting, 61 | ed2kId = statsResponse.connectionState?.ed2kId, 62 | kadConnected = statsResponse.connectionState?.kadConnected, 63 | kadFirewalled = statsResponse.connectionState?.kadFirewalled, 64 | kadId = statsResponse.connectionState?.kadId, 65 | kadRunning = statsResponse.connectionState?.kadRunning, 66 | serverDescription = statsResponse.connectionState?.serverDescription, 67 | serverFailed = statsResponse.connectionState?.serverFailed, 68 | serverFiles = statsResponse.connectionState?.serverFiles, 69 | serverIpv4 = statsResponse.connectionState?.serverIpv4?.address, 70 | serverPing = statsResponse.connectionState?.serverPing, 71 | serverPrio = statsResponse.connectionState?.serverPrio, 72 | serverStatic = statsResponse.connectionState?.serverStatic, 73 | serverUsers = statsResponse.connectionState?.serverUsers, 74 | serverUsersMax = statsResponse.connectionState?.serverUsersMax, 75 | serverVersion = statsResponse.connectionState?.serverVersion 76 | ), 77 | downloadOverhead = statsResponse.downloadOverhead, 78 | downloadSpeed = statsResponse.downloadSpeed, 79 | downloadSpeedLimit = statsResponse.downloadSpeedLimit, 80 | ed2kFiles = statsResponse.ed2kFiles, 81 | ed2kUsers = statsResponse.ed2kUsers, 82 | kadFiles = statsResponse.kadFiles, 83 | kadFirewalledUdp = statsResponse.kadFirewalledUdp, 84 | kadIndexedKeywords = statsResponse.kadIndexedKeywords, 85 | kadIndexedLoad = statsResponse.kadIndexedLoad, 86 | kadIndexedNotes = statsResponse.kadIndexedNotes, 87 | kadIndexedSources = statsResponse.kadIndexedSources, 88 | kadIpAddress = statsResponse.kadIpAddress, 89 | kadIsRunningInLanMode = statsResponse.kadIsRunningInLanMode, 90 | kadNodes = statsResponse.kadNodes, 91 | kadUsers = statsResponse.kadUsers, 92 | loggerMessage = statsResponse.loggerMessage, 93 | sharedFileCount = statsResponse.sharedFileCount, 94 | totalReceivedBytes = statsResponse.totalReceivedBytes, 95 | totalSentBytes = statsResponse.totalSentBytes, 96 | totalSourceCount = statsResponse.totalSourceCount, 97 | uploadOverhead = statsResponse.uploadOverhead, 98 | uploadQueueLength = statsResponse.uploadQueueLength, 99 | uploadSpeed = statsResponse.uploadSpeed, 100 | uploadSpeedLimit = statsResponse.uploadSpeedLimit 101 | ) 102 | } 103 | } 104 | 105 | @Serializable 106 | data class ConnectionStatus( 107 | val clientId: Int? = null, 108 | val ed2kConnected: Boolean? = null, 109 | val ed2kConnecting: Boolean? = null, 110 | val ed2kId: Int? = null, 111 | val kadConnected: Boolean? = null, 112 | val kadFirewalled: Boolean? = null, 113 | val kadId: Int? = null, 114 | val kadRunning: Boolean? = null, 115 | val serverDescription: String? = null, 116 | val serverFailed: Int? = null, 117 | val serverFiles: Int? = null, 118 | val serverIpv4: String? = null, 119 | val serverPing: Int? = null, 120 | val serverPrio: Int? = null, 121 | val serverStatic: Boolean? = null, 122 | val serverUsers: Int? = null, 123 | val serverUsersMax: Int? = null, 124 | val serverVersion: String? = null 125 | ) -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/torrent/model/TorrentInfo.kt: -------------------------------------------------------------------------------- 1 | package amarr.torrent.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | /** 6 | * Full documentation of the qBittorrent API can be found here: 7 | * https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1) 8 | */ 9 | @Serializable 10 | data class TorrentInfo( 11 | // Following values are used by Radarr 12 | val hash: String, // Torrent hash 13 | val name: String, // Torrent name 14 | val size: Long, // Total size (bytes) of files selected for download 15 | val progress: Double, // Torrent progress (percentage/100) 16 | val eta: Int, // Torrent ETA (seconds) the value 8640000 indicates that there is no ETA available 17 | val state: TorrentState, // Torrent state 18 | val category: String?, // Category of the torrent 19 | val save_path: String, // Path where this torrent's data is stored 20 | 21 | // Following are not used by Radarr but are handled by amarr 22 | val dlspeed: Long, // Torrent download speed (bytes/s) 23 | val num_seeds: Int, // Number of seeders connected to this torrent 24 | val priority: Int, // Torrent priority. Returns -1 if queuing is disabled or torrent is in seed mode 25 | val total_size: Long, // Total size (bytes) of all file in torrent 26 | val downloaded: Long, // Amount of data (bytes) downloaded since torrent was started 27 | 28 | // Following are parsed by Radarr but not handled by amarr yet 29 | // TODO: Handle these values 30 | val content_path: String = "", // Subpath where this torrent's data is stored. Only available for multifile torrents 31 | val ratio: Double = 0.0, // Torrent share ratio. Max ratio value: 9999. 32 | val ratio_limit: Int = -2, // Max share ratio until torrent is stopped from seeding/uploading -2 = Use global share ratio limit -1 = Unlimited 33 | val seeding_time: Int = 0, // Total time (seconds) this torrent has been seeding 34 | val seeding_time_limit: Int = -2, // Max seeding time (seconds) until torrent is stopped from seeding -2 = Use global seeding time limit -1 = Unlimited 35 | 36 | // TODO This is not parsed by Radarr but should be handled by amarr 37 | 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", 38 | 39 | // Following values are not handled by amarr 40 | val upspeed: Long = 0, // Torrent upload speed (bytes/s) 41 | val num_leechs: Int = 0, // Number of leechers connected to this torrent 42 | val tags: String = "", // Comma-concatenated tag list of the torrent 43 | val super_seeding: Boolean = false, // True if super seeding is enabled 44 | val added_on: Long = 1696781958, // TODO: Change (UTC timestamp) 45 | val amount_left: Int = 0, 46 | val auto_tmm: Boolean = false, 47 | val availability: Int = 0, 48 | val completed: Int = 0, 49 | val completion_on: Int = 0, 50 | val dl_limit: Int = 0, 51 | val download_path: String = "", 52 | val downloaded_session: Int = 0, 53 | val f_l_piece_prio: Boolean = false, 54 | val force_start: Boolean = false, 55 | val last_activity: Long = 1696781958, // TODO: Change (UTC timestamp) 56 | val max_ratio: Int = -1, 57 | val max_seeding_time: Int = -1, 58 | val num_complete: Int = -1, 59 | val num_incomplete: Int = -1, 60 | val seen_complete: Int = 0, 61 | val seq_dl: Boolean = false, 62 | val time_active: Int = 309, 63 | val tracker: String = "http://tracker.openbittorrent.com:80/announce", 64 | val trackers_count: Int = 20, 65 | val up_limit: Int = 0, 66 | val uploaded: Int = 0, 67 | val uploaded_session: Int = 0, 68 | ) 69 | 70 | enum class TorrentState { 71 | // Maps to an error in Radarr (Status "Warning") 72 | error, // Some error occurred, applies to paused torrents 73 | stalledDL, // Torrent is being downloaded, but no connection were made 74 | missingFiles, // Torrent data files is missing 75 | 76 | // Maps to the "Paused" state in Radarr 77 | pausedDL, // Torrent is paused and has NOT finished downloading 78 | 79 | // All map to the "Queued" state in Radarr 80 | queuedDL, // Queuing is enabled and torrent is queued for download 81 | checkingDL, // Same as checkingUP, but torrent has NOT finished downloading 82 | checkingUP, // Torrent has finished downloading and is being checked 83 | checkingResumeData, // Checking resume data on qBt startup 84 | 85 | // All map to the "Completed" state in Radarr 86 | pausedUP, // Torrent is paused and has finished downloading 87 | uploading, // Torrent is being seeded and data is being transferred 88 | stalledUP, // Torrent is being seeded, but no connection were made 89 | queuedUP, // Queuing is enabled and torrent is queued for upload 90 | forcedUP, // Torrent is forced to uploading and ignore queue limit 91 | 92 | // Maps to the "Queued" state in Radarr only if Dht is enabled, else "Warning" 93 | metaDL, // Torrent has just started downloading and is fetching metadata 94 | 95 | // Maps to the "Downloading" state in Radarr 96 | forcedDL, // Torrent is forced to downloading to ignore queue limit 97 | moving, // Torrent is moving to another location 98 | downloading, // Torrent is being downloaded and data is being transferred 99 | 100 | // Maps to the "Unknown" state in Radarr 101 | allocating, // Torrent is allocating disk space for download 102 | unknown, // Unknown status 103 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/torrent/TorrentService.kt: -------------------------------------------------------------------------------- 1 | package amarr.torrent 2 | 3 | import amarr.MagnetLink 4 | import amarr.category.CategoryStore 5 | import amarr.torrent.model.* 6 | import io.ktor.server.plugins.* 7 | import io.ktor.util.logging.* 8 | import jamule.AmuleClient 9 | import jamule.model.AmuleTransferringFile 10 | import jamule.model.DownloadCommand 11 | import jamule.model.FileStatus 12 | import kotlin.io.path.Path 13 | 14 | class TorrentService( 15 | private val amuleClient: AmuleClient, 16 | private val categoryStore: CategoryStore, 17 | private val finishedPath: String, 18 | private val log: Logger 19 | ) { 20 | 21 | fun getTorrentInfo(category: String?): List { 22 | val downloadingFiles = amuleClient 23 | .getDownloadQueue() 24 | .getOrThrow() 25 | val sharedFiles = amuleClient.getSharedFiles().getOrThrow() 26 | val downloadingFilesHashSet = downloadingFiles.map { it.fileHashHexString }.toHashSet() 27 | 28 | val allFiles = (sharedFiles // Downloading files also appear in shared files 29 | .filterNot { downloadingFilesHashSet.contains(it.fileHashHexString) } + downloadingFiles) 30 | .filter { category == null || categoryStore.getCategory(it.fileHashHexString!!) == category } 31 | 32 | return allFiles 33 | .map { dl -> 34 | if (dl is AmuleTransferringFile) 35 | TorrentInfo( 36 | hash = dl.fileHashHexString!!, 37 | name = dl.fileName!!, 38 | size = dl.sizeFull!!, 39 | total_size = dl.sizeFull!!, 40 | save_path = finishedPath, 41 | downloaded = dl.sizeDone!!, 42 | progress = dl.sizeDone!!.toDouble() / dl.sizeFull!!.toDouble(), 43 | priority = dl.downPrio.toInt(), 44 | state = if (dl.sourceXferCount > 0) TorrentState.downloading 45 | else when (dl.fileStatus) { 46 | FileStatus.READY -> TorrentState.metaDL 47 | FileStatus.ERROR -> TorrentState.error 48 | FileStatus.COMPLETING -> TorrentState.checkingDL 49 | FileStatus.COMPLETE -> TorrentState.uploading 50 | FileStatus.PAUSED -> TorrentState.pausedDL 51 | FileStatus.ALLOCATING -> TorrentState.allocating 52 | FileStatus.INSUFFICIENT -> TorrentState.error 53 | .also { log.error("Insufficient disk space") } 54 | 55 | else -> TorrentState.unknown 56 | }, 57 | category = category, 58 | dlspeed = dl.speed!!, 59 | num_seeds = dl.sourceXferCount.toInt(), 60 | eta = computeEta(dl.speed!!, dl.sizeFull!!, dl.sizeDone!!), 61 | ) 62 | else 63 | // File is already fully downloaded 64 | TorrentInfo( 65 | hash = dl.fileHashHexString!!, 66 | name = dl.fileName!!, 67 | size = dl.sizeFull!!, 68 | total_size = dl.sizeFull!!, 69 | save_path = finishedPath, 70 | dlspeed = 0, 71 | downloaded = dl.sizeFull!!, 72 | progress = 1.0, 73 | priority = 0, 74 | state = TorrentState.uploading, 75 | category = category, 76 | eta = 0, 77 | num_seeds = 0, // Irrelevant 78 | ) 79 | } 80 | } 81 | 82 | private fun computeEta(speed: Long, sizeFull: Long, sizeDone: Long): Int { 83 | val remainingBytes = sizeFull - sizeDone 84 | return if (speed == 0L) 8640000 else Math.min((remainingBytes / speed).toInt(), 8640000) 85 | } 86 | 87 | fun getCategories(): Map = categoryStore 88 | .getCategories() 89 | .associateBy { it.name } 90 | 91 | fun addCategory(category: Category) = categoryStore.addCategory(category) 92 | 93 | fun addTorrent(urls: List?, category: String?, paused: String?) { 94 | if (urls == null) { 95 | log.error("No urls provided") 96 | throw nonAmarrLink("No urls provided") 97 | } 98 | urls.forEach { url -> 99 | val magnetLink = try { 100 | MagnetLink.fromString(url) 101 | } catch (e: Exception) { 102 | throw nonAmarrLink(url) 103 | } 104 | if (!magnetLink.isAmarr()) { 105 | throw nonAmarrLink(url) 106 | } 107 | amuleClient.downloadEd2kLink(magnetLink.toEd2kLink()) 108 | if (category != null) { 109 | categoryStore.store(category, magnetLink.amuleHexHash()) 110 | } 111 | } 112 | } 113 | 114 | @OptIn(ExperimentalStdlibApi::class) 115 | fun deleteTorrent(hashes: List, deleteFiles: String?) { 116 | val downloadingFiles = amuleClient 117 | .getDownloadQueue() 118 | .getOrThrow() 119 | hashes.forEach { hash -> 120 | if (downloadingFiles.any { it.fileHashHexString == hash }) { 121 | amuleClient.sendDownloadCommand(hash.hexToByteArray(), DownloadCommand.DELETE) 122 | } else if (deleteFiles == "true") { 123 | deleteSharedFileByHash(hash) 124 | } else { 125 | log.error("File with hash $hash not found in downloading files") 126 | } 127 | categoryStore.delete(hash) 128 | } 129 | } 130 | 131 | @OptIn(ExperimentalStdlibApi::class) 132 | fun deleteAllTorrents(deleteFiles: String?) = amuleClient.getSharedFiles().getOrThrow().forEach { file -> 133 | amuleClient.sendDownloadCommand(file.fileHashHexString!!.hexToByteArray(), DownloadCommand.DELETE) 134 | categoryStore.delete(file.fileHashHexString!!) 135 | } 136 | 137 | fun getFile(hash: String) = getTorrentInfo(null) 138 | .first { it.hash == hash } 139 | .let { 140 | TorrentFile( 141 | name = it.name, 142 | ) 143 | } 144 | 145 | fun getTorrentProperties(hash: String): TorrentProperties = getTorrentInfo(null) 146 | .first { it.hash == hash } 147 | .let { 148 | TorrentProperties( 149 | hash = it.hash, 150 | save_path = it.save_path, 151 | seeding_time = 0, 152 | ) 153 | } 154 | 155 | private fun deleteSharedFileByHash(hash: String) = amuleClient 156 | .getSharedFiles() 157 | .getOrThrow() 158 | .firstOrNull { it.fileHashHexString == hash } 159 | ?.filePath 160 | ?.let { Path(it).toFile().delete() } 161 | ?: log.error("File with hash $hash not found in shared files") 162 | 163 | private fun nonAmarrLink(url: String): Exception { 164 | log.error( 165 | "The provided link does not appear to be an Amarr link: {}. " + 166 | "Have you configured Radarr/Sonarr's download client priority correctly? See README.md", url 167 | ) 168 | return NotFoundException("The provided link does not appear to be an Amarr link: $url") 169 | } 170 | 171 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/amarr/torrent/model/Preferences.kt: -------------------------------------------------------------------------------- 1 | package amarr.torrent.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class Preferences( 7 | val add_trackers: String = "", 8 | val add_trackers_enabled: Boolean = false, 9 | val alt_dl_limit: Int = 0, 10 | val alt_up_limit: Int = 3072000, 11 | val alternative_webui_enabled: Boolean = false, 12 | val alternative_webui_path: String = "", 13 | val announce_ip: String = "", 14 | val announce_to_all_tiers: Boolean = true, 15 | val announce_to_all_trackers: Boolean = false, 16 | val anonymous_mode: Boolean = false, 17 | val async_io_threads: Int = 10, 18 | val auto_delete_mode: Int = 0, 19 | val auto_tmm_enabled: Boolean = false, 20 | val autorun_enabled: Boolean = false, 21 | val autorun_on_torrent_added_enabled: Boolean = false, 22 | val autorun_on_torrent_added_program: String = "", 23 | val autorun_program: String = "", 24 | val banned_IPs: String = "", 25 | val bittorrent_protocol: Int = 0, 26 | val block_peers_on_privileged_ports: Boolean = false, 27 | val bypass_auth_subnet_whitelist: String = "0.0.0.0/0", 28 | val bypass_auth_subnet_whitelist_enabled: Boolean = true, 29 | val bypass_local_auth: Boolean = true, 30 | val category_changed_tmm_enabled: Boolean = false, 31 | val checking_memory_use: Int = 32, 32 | val connection_speed: Int = 30, 33 | val current_interface_address: String = "", 34 | val current_network_interface: String = "", 35 | val dht: Boolean = true, 36 | val disk_cache: Int = -1, 37 | val disk_cache_ttl: Int = 60, 38 | val disk_io_read_mode: Int = 1, 39 | val disk_io_type: Int = 0, 40 | val disk_io_write_mode: Int = 1, 41 | val disk_queue_size: Int = 1048576, 42 | val dl_limit: Int = 0, 43 | val dont_count_slow_torrents: Boolean = true, 44 | val dyndns_domain: String = "changeme.dyndns.org", 45 | val dyndns_enabled: Boolean = false, 46 | val dyndns_password: String = "", 47 | val dyndns_service: Int = 0, 48 | val dyndns_username: String = "", 49 | val embedded_tracker_port: Int = 9000, 50 | val embedded_tracker_port_forwarding: Boolean = false, 51 | val enable_coalesce_read_write: Boolean = false, 52 | val enable_embedded_tracker: Boolean = false, 53 | val enable_multi_connections_from_same_ip: Boolean = false, 54 | val enable_piece_extent_affinity: Boolean = false, 55 | val enable_upload_suggestions: Boolean = false, 56 | val encryption: Int = 0, 57 | val excluded_file_names: String = "", 58 | val excluded_file_names_enabled: Boolean = false, 59 | val export_dir: String = "", 60 | val export_dir_fin: String = "", 61 | val file_pool_size: Int = 5000, 62 | val hashing_threads: Int = 1, 63 | val idn_support_enabled: Boolean = false, 64 | val incomplete_files_ext: Boolean = false, 65 | val ip_filter_enabled: Boolean = false, 66 | val ip_filter_path: String = "", 67 | val ip_filter_trackers: Boolean = false, 68 | val limit_lan_peers: Boolean = true, 69 | val limit_tcp_overhead: Boolean = false, 70 | val limit_utp_rate: Boolean = true, 71 | val listen_port: Int = 6881, 72 | val locale: String = "en", 73 | val lsd: Boolean = true, 74 | val mail_notification_auth_enabled: Boolean = true, 75 | val mail_notification_email: String = "", 76 | val mail_notification_enabled: Boolean = false, 77 | val mail_notification_password: String = "", 78 | val mail_notification_sender: String = "qBittorrent_notification@example.com", 79 | val mail_notification_smtp: String = "smtp.changeme.com", 80 | val mail_notification_ssl_enabled: Boolean = false, 81 | val mail_notification_username: String = "", 82 | val max_active_checking_torrents: Int = 1, 83 | val max_active_downloads: Int = 20, 84 | val max_active_torrents: Int = 10, 85 | val max_active_uploads: Int = 5, 86 | val max_concurrent_http_announces: Int = 50, 87 | val max_connec: Int = 500, 88 | val max_connec_per_torrent: Int = 100, 89 | val max_ratio: Int = 2, 90 | val max_ratio_act: Int = 3, 91 | val max_ratio_enabled: Boolean = false, 92 | val max_seeding_time: Int = 259200, 93 | val max_seeding_time_enabled: Boolean = false, 94 | val max_uploads: Int = 20, 95 | val max_uploads_per_torrent: Int = 4, 96 | val memory_working_set_limit: Int = 512, 97 | val outgoing_ports_max: Int = 0, 98 | val outgoing_ports_min: Int = 0, 99 | val peer_tos: Int = 4, 100 | val peer_turnover: Int = 4, 101 | val peer_turnover_cutoff: Int = 90, 102 | val peer_turnover_interval: Int = 300, 103 | val performance_warning: Boolean = false, 104 | val pex: Boolean = true, 105 | val preallocate_all: Boolean = false, 106 | val proxy_auth_enabled: Boolean = false, 107 | val proxy_hostname_lookup: Boolean = true, 108 | val proxy_ip: String = "0.0.0.0", 109 | val proxy_password: String = "", 110 | val proxy_peer_connections: Boolean = false, 111 | val proxy_port: Int = 8080, 112 | val proxy_torrents_only: Boolean = false, 113 | val proxy_type: Int = 0, 114 | val proxy_username: String = "", 115 | val queueing_enabled: Boolean = true, 116 | val random_port: Boolean = false, 117 | val reannounce_when_address_changed: Boolean = false, 118 | val recheck_completed_torrents: Boolean = false, 119 | val refresh_interval: Int = 1500, 120 | val request_queue_size: Int = 500, 121 | val resolve_peer_countries: Boolean = true, 122 | val resume_data_storage_type: String = "Legacy", 123 | val rss_auto_downloading_enabled: Boolean = false, 124 | val rss_download_repack_proper_episodes: Boolean = true, 125 | val rss_max_articles_per_feed: Int = 50, 126 | val rss_processing_enabled: Boolean = false, 127 | val rss_refresh_interval: Int = 30, 128 | 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})", 129 | val save_path: String, 130 | val save_path_changed_tmm_enabled: Boolean = false, 131 | val save_resume_data_interval: Int = 60, 132 | val schedule_from_hour: Int = 8, 133 | val schedule_from_min: Int = 0, 134 | val schedule_to_hour: Int = 20, 135 | val schedule_to_min: Int = 0, 136 | val scheduler_days: Int = 0, 137 | val scheduler_enabled: Boolean = true, 138 | val send_buffer_low_watermark: Int = 10, 139 | val send_buffer_watermark: Int = 500, 140 | val send_buffer_watermark_factor: Int = 50, 141 | val slow_torrent_dl_rate_threshold: Int = 1000, 142 | val slow_torrent_inactive_timer: Int = 60, 143 | val slow_torrent_ul_rate_threshold: Int = 80, 144 | val socket_backlog_size: Int = 30, 145 | val ssrf_mitigation: Boolean = true, 146 | val start_paused_enabled: Boolean = false, 147 | val stop_tracker_timeout: Int = 5, 148 | val temp_path: String = "/downloads/incomplete", 149 | val temp_path_enabled: Boolean = false, 150 | val torrent_changed_tmm_enabled: Boolean = true, 151 | val torrent_content_layout: String = "Original", 152 | val torrent_stop_condition: String = "None", 153 | val up_limit: Int = 10240000, 154 | val upload_choking_algorithm: Int = 1, 155 | val upload_slots_behavior: Int = 0, 156 | val upnp: Boolean = false, 157 | val upnp_lease_duration: Int = 0, 158 | val use_category_paths_in_manual_mode: Boolean = false, 159 | val use_https: Boolean = false, 160 | val utp_tcp_mixed_mode: Int = 0, 161 | val validate_https_tracker_certificate: Boolean = true, 162 | val web_ui_address: String = "*", 163 | val web_ui_ban_duration: Int = 3600, 164 | val web_ui_clickjacking_protection_enabled: Boolean = true, 165 | val web_ui_csrf_protection_enabled: Boolean = true, 166 | val web_ui_custom_http_headers: String = "", 167 | val web_ui_domain_list: String = "*", 168 | val web_ui_host_header_validation_enabled: Boolean = true, 169 | val web_ui_https_cert_path: String = "", 170 | val web_ui_https_key_path: String = "", 171 | val web_ui_max_auth_fail_count: Int = 5, 172 | val web_ui_port: Int = 8055, 173 | val web_ui_reverse_proxies_list: String = "", 174 | val web_ui_reverse_proxy_enabled: Boolean = false, 175 | val web_ui_secure_cookie_enabled: Boolean = true, 176 | val web_ui_session_timeout: Int = 3600, 177 | val web_ui_upnp: Boolean = true, 178 | val web_ui_use_custom_http_headers_enabled: Boolean = false, 179 | val web_ui_username: String = "oslinux" 180 | ) 181 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /app/src/test/kotlin/amarr/torrent/TorrentApiTest.kt: -------------------------------------------------------------------------------- 1 | package amarr.torrent 2 | 3 | import amarr.MagnetLink 4 | import amarr.category.CategoryStore 5 | import amarr.torrent.model.Category 6 | import io.kotest.core.spec.style.StringSpec 7 | import io.kotest.matchers.shouldBe 8 | import io.ktor.client.request.* 9 | import io.ktor.client.request.forms.* 10 | import io.ktor.http.* 11 | import io.ktor.serialization.kotlinx.json.* 12 | import io.ktor.server.application.* 13 | import io.ktor.server.plugins.contentnegotiation.* 14 | import io.ktor.server.testing.* 15 | import io.mockk.clearAllMocks 16 | import io.mockk.every 17 | import io.mockk.mockk 18 | import io.mockk.verify 19 | import jamule.AmuleClient 20 | import jamule.model.AmuleTransferringFile 21 | import jamule.model.DownloadCommand 22 | import jamule.model.FileStatus 23 | import kotlinx.serialization.json.Json 24 | import java.nio.file.Files 25 | 26 | class TorrentApiTest : StringSpec({ 27 | val amuleClient = mockk() 28 | val categoryStore = MemoryCategoryStore() 29 | val testMagnetHash = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15) 30 | val testMagnetLink = MagnetLink.forAmarr(testMagnetHash, "test", 1) 31 | val finishedPath = "/finished" 32 | 33 | beforeAny { 34 | clearAllMocks() 35 | } 36 | 37 | "should get preferences" { 38 | testApplication { 39 | application { 40 | torrentApi(amuleClient, categoryStore, finishedPath) 41 | configureForTest() 42 | } 43 | client.get("/api/v2/app/preferences").apply { 44 | this.status shouldBe HttpStatusCode.OK 45 | } 46 | } 47 | } 48 | 49 | "should get api version" { 50 | testApplication { 51 | application { 52 | torrentApi(amuleClient, categoryStore, finishedPath) 53 | configureForTest() 54 | } 55 | client.get("/api/v2/app/webapiVersion").apply { 56 | this.status shouldBe HttpStatusCode.OK 57 | } 58 | } 59 | } 60 | 61 | "should allow login" { 62 | testApplication { 63 | application { 64 | torrentApi(amuleClient, categoryStore, finishedPath) 65 | configureForTest() 66 | } 67 | client.submitForm(formParameters = Parameters.build { 68 | append("username", "test") 69 | append("password", "test") 70 | }, url = "/api/v2/auth/login").apply { 71 | this.status shouldBe HttpStatusCode.OK 72 | } 73 | } 74 | } 75 | 76 | "should add torrent" { 77 | testApplication { 78 | application { 79 | torrentApi(amuleClient, categoryStore, finishedPath) 80 | configureForTest() 81 | } 82 | val urls = listOf(testMagnetLink.toString()) 83 | val ed2k = testMagnetLink.toEd2kLink() 84 | every { amuleClient.downloadEd2kLink(ed2k) } returns Result.success(Unit) 85 | client.submitForm(formParameters = Parameters.build { 86 | appendAll("urls", urls) 87 | append("category", "test") 88 | append("paused", "test") 89 | }, url = "/api/v2/torrents/add").apply { 90 | this.status shouldBe HttpStatusCode.OK 91 | } 92 | } 93 | } 94 | 95 | "should get categories" { 96 | testApplication { 97 | application { 98 | torrentApi(amuleClient, categoryStore, finishedPath) 99 | configureForTest() 100 | } 101 | client.get("/api/v2/torrents/categories").apply { 102 | this.status shouldBe HttpStatusCode.OK 103 | } 104 | } 105 | } 106 | 107 | "should create category" { 108 | testApplication { 109 | application { 110 | torrentApi(amuleClient, categoryStore, finishedPath) 111 | configureForTest() 112 | } 113 | client.submitForm(formParameters = Parameters.build { 114 | append("category", "test") 115 | append("savePath", "test") 116 | }, url = "/api/v2/torrents/createCategory").apply { 117 | this.status shouldBe HttpStatusCode.OK 118 | } 119 | } 120 | } 121 | 122 | "should delete torrent when downloading" { 123 | testApplication { 124 | application { 125 | torrentApi(amuleClient, categoryStore, finishedPath) 126 | configureForTest() 127 | } 128 | categoryStore.store("test", testMagnetLink.amuleHexHash()) 129 | every { 130 | amuleClient.sendDownloadCommand(testMagnetHash, DownloadCommand.DELETE) 131 | } returns Result.success(Unit) 132 | every { 133 | amuleClient.getDownloadQueue() 134 | } returns Result.success( 135 | listOf( 136 | MockTransferringFile( 137 | fileHashHexString = testMagnetLink.amuleHexHash(), 138 | fileName = testMagnetLink.name, 139 | sizeFull = testMagnetLink.size, 140 | ) 141 | ) 142 | ) 143 | client.submitForm(formParameters = Parameters.build { 144 | append("hashes", testMagnetLink.amuleHexHash()) 145 | append("deleteFiles", "true") 146 | }, url = "/api/v2/torrents/delete").apply { 147 | this.status shouldBe HttpStatusCode.OK 148 | } 149 | verify { amuleClient.sendDownloadCommand(testMagnetHash, DownloadCommand.DELETE) } 150 | categoryStore.getCategory(testMagnetLink.amuleHexHash()) shouldBe null 151 | } 152 | } 153 | 154 | "should delete file when not downloading" { 155 | testApplication { 156 | application { 157 | torrentApi(amuleClient, categoryStore, finishedPath) 158 | configureForTest() 159 | } 160 | categoryStore.store("test", testMagnetLink.amuleHexHash()) 161 | every { 162 | amuleClient.sendDownloadCommand(testMagnetHash, DownloadCommand.DELETE) 163 | } returns Result.success(Unit) 164 | val randomTemporaryFile = Files.createTempFile("test", "test") 165 | every { amuleClient.getSharedFiles() } returns Result.success( 166 | listOf( 167 | MockTransferringFile( 168 | fileHashHexString = testMagnetLink.amuleHexHash(), 169 | fileName = testMagnetLink.name, 170 | sizeFull = testMagnetLink.size, 171 | filePath = randomTemporaryFile.toAbsolutePath().toString() 172 | ) 173 | ) 174 | ) 175 | every { amuleClient.getDownloadQueue() } returns Result.success(emptyList()) 176 | client.submitForm(formParameters = Parameters.build { 177 | append("hashes", testMagnetLink.amuleHexHash()) 178 | append("deleteFiles", "true") 179 | }, url = "/api/v2/torrents/delete").apply { 180 | this.status shouldBe HttpStatusCode.OK 181 | } 182 | verify(exactly = 0) { amuleClient.sendDownloadCommand(testMagnetHash, DownloadCommand.DELETE) } 183 | categoryStore.getCategory(testMagnetLink.amuleHexHash()) shouldBe null 184 | Files.exists(randomTemporaryFile) shouldBe false 185 | } 186 | } 187 | 188 | "should get files" { 189 | testApplication { 190 | application { 191 | torrentApi(amuleClient, categoryStore, finishedPath) 192 | configureForTest() 193 | } 194 | amuleClient.addToDownloadQueue(testMagnetLink) 195 | every { amuleClient.getSharedFiles() } returns Result.success(emptyList()) 196 | client.get { 197 | url("/api/v2/torrents/files") 198 | parameter("hash", testMagnetLink.amuleHexHash()) 199 | }.apply { 200 | this.status shouldBe HttpStatusCode.OK 201 | } 202 | } 203 | } 204 | 205 | "should get info" { 206 | testApplication { 207 | application { 208 | torrentApi(amuleClient, categoryStore, finishedPath) 209 | configureForTest() 210 | } 211 | amuleClient.addToDownloadQueue(testMagnetLink) 212 | every { amuleClient.getSharedFiles() } returns Result.success(emptyList()) 213 | client.get { 214 | url("/api/v2/torrents/info") 215 | parameter("category", "test") 216 | }.apply { 217 | this.status shouldBe HttpStatusCode.OK 218 | } 219 | } 220 | } 221 | 222 | "should get properties" { 223 | testApplication { 224 | application { 225 | torrentApi(amuleClient, categoryStore, finishedPath) 226 | configureForTest() 227 | } 228 | amuleClient.addToDownloadQueue(testMagnetLink) 229 | every { amuleClient.getSharedFiles() } returns Result.success(emptyList()) 230 | client.get { 231 | url("/api/v2/torrents/properties") 232 | parameter("hash", testMagnetLink.amuleHexHash()) 233 | }.apply { 234 | this.status shouldBe HttpStatusCode.OK 235 | } 236 | } 237 | } 238 | }) 239 | 240 | private fun AmuleClient.addToDownloadQueue(magnetLink: MagnetLink) { 241 | every { this@addToDownloadQueue.getDownloadQueue() } returns Result.success( 242 | listOf( 243 | MockTransferringFile( 244 | fileHashHexString = magnetLink.amuleHexHash(), 245 | fileName = magnetLink.name, 246 | sizeFull = magnetLink.size, 247 | ) 248 | ) 249 | ) 250 | } 251 | 252 | private fun Application.configureForTest() { 253 | install(ContentNegotiation) { 254 | json(Json { 255 | ignoreUnknownKeys = true 256 | isLenient = true 257 | prettyPrint = true 258 | encodeDefaults = true 259 | }) 260 | } 261 | } 262 | 263 | private class MemoryCategoryStore() : CategoryStore { 264 | 265 | private val categories = mutableSetOf() 266 | private val hashes = mutableMapOf() 267 | 268 | override fun store(category: String, hash: String) { 269 | hashes[hash] = category 270 | } 271 | 272 | override fun getCategory(hash: String): String? { 273 | return hashes[hash] 274 | } 275 | 276 | override fun delete(hash: String) { 277 | hashes.remove(hash) 278 | } 279 | 280 | override fun addCategory(category: Category) { 281 | categories.add(category) 282 | } 283 | 284 | override fun getCategories(): Set { 285 | return categories 286 | } 287 | 288 | } 289 | 290 | private data class MockTransferringFile( 291 | override val fileHashHexString: String? = null, 292 | override val partMetID: Short? = 0, 293 | override val sizeXfer: Long? = 0, 294 | override val sizeDone: Long? = 0, 295 | override val fileStatus: FileStatus = FileStatus.UNKNOWN, 296 | override val stopped: Boolean = false, 297 | override val sourceCount: Short = 0, 298 | override val sourceNotCurrCount: Short = 0, 299 | override val sourceXferCount: Short = 0, 300 | override val sourceCountA4AF: Short = 0, 301 | override val speed: Long? = 0, 302 | override val downPrio: Byte = 0, 303 | override val fileCat: Long = 0, 304 | override val lastSeenComplete: Long = 0, 305 | override val lastDateChanged: Long = 0, 306 | override val downloadActiveTime: Int = 0, 307 | override val availablePartCount: Short = 0, 308 | override val a4AFAuto: Boolean = false, 309 | override val hashingProgress: Boolean = false, 310 | override val getLostDueToCorruption: Long = 0, 311 | override val getGainDueToCompression: Long = 0, 312 | override val totalPacketsSavedDueToICH: Int = 0, 313 | override val fileName: String? = null, 314 | override val filePath: String? = null, 315 | override val sizeFull: Long? = 0, 316 | override val fileEd2kLink: String? = null, 317 | override val upPrio: Byte = 0, 318 | override val getRequests: Short = 0, 319 | override val getAllRequests: Int = 0, 320 | override val getAccepts: Short = 0, 321 | override val getAllAccepts: Int = 0, 322 | override val getXferred: Long = 0, 323 | override val getAllXferred: Long = 0, 324 | override val getCompleteSourcesLow: Short = 0, 325 | override val getCompleteSourcesHigh: Short = 0, 326 | override val getCompleteSources: Short = 0, 327 | override val getOnQueue: Short = 0, 328 | override val getComment: String? = null, 329 | override val getRating: Byte? = 0, 330 | ) : AmuleTransferringFile 331 | --------------------------------------------------------------------------------