├── .github └── workflows │ └── gradle.yml ├── .gitignore ├── README.md ├── build.gradle.kts ├── buildSrc ├── build.gradle.kts └── src │ └── main │ └── kotlin │ ├── com.github.schaka.build │ └── Build.kt │ └── project-extensions.kt ├── docs └── img │ ├── unraid.png │ └── webhook.png ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── src └── main ├── kotlin └── com │ └── github │ └── schaka │ └── rarrnomore │ ├── RarrnomoreApplication.kt │ ├── hooks │ ├── HookController.kt │ ├── TorrentInfo.kt │ ├── WebHookRequest.kt │ ├── radarr │ │ ├── RadarrMovie.kt │ │ ├── RadarrRelease.kt │ │ └── RadarrWebHookRequest.kt │ └── sonarr │ │ ├── SonarrRelease.kt │ │ ├── SonarrSeries.kt │ │ └── SonarrWebHookRequest.kt │ ├── servarr │ ├── ServarrClientConfig.kt │ ├── ServarrService.kt │ ├── TorrentNotInQueueException.kt │ ├── radarr │ │ ├── Radarr.kt │ │ ├── RadarrProperties.kt │ │ ├── RadarrQueueItem.kt │ │ ├── RadarrQueueList.kt │ │ └── RadarrService.kt │ └── sonarr │ │ ├── SonarQueueItem.kt │ │ ├── Sonarr.kt │ │ ├── SonarrProperties.kt │ │ ├── SonarrQueueList.kt │ │ └── SonarrService.kt │ └── torrent │ ├── TorrentClientType.kt │ ├── TorrentHashNotFoundException.kt │ ├── TorrentManager.kt │ ├── TorrentQueueItem.kt │ ├── TorrentService.kt │ ├── TorrentsServiceResolver.kt │ ├── qbit │ ├── QBittorrent.kt │ ├── QBittorrentService.kt │ ├── QbitAuthInterceptor.kt │ └── QbitFileResponse.kt │ ├── rest │ ├── TorrentClientConfig.kt │ └── TorrentClientProperties.kt │ └── transmission │ ├── Transmission.kt │ ├── TransmissionAuthHandler.kt │ ├── TransmissionRequest.kt │ ├── TransmissionResponse.kt │ ├── TransmissionService.kt │ └── TransmissionTorrentResponse.kt └── resources └── application.yml /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # GitHub recommends pinning actions to a commit SHA. 7 | # To get a newer version, you will need to update the SHA. 8 | # You can also reference a tag or branch, but the action may change without warning. 9 | 10 | on: 11 | push: 12 | tags: 13 | - '**' 14 | branches: 15 | - 'develop' 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up JDK 21 24 | uses: actions/setup-java@v4 25 | with: 26 | java-version: '21' 27 | distribution: 'temurin' 28 | 29 | - name: Validate Gradle wrapper 30 | uses: gradle/wrapper-validation-action@v2 31 | 32 | - name: Log in to Docker Hub 33 | uses: docker/login-action@v3 34 | with: 35 | username: ${{ secrets.DOCKERHUB_USER }} 36 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 37 | 38 | - name: Build with Gradle 39 | uses: gradle/actions/setup-gradle@v3 40 | with: 41 | gradle-version: 8.7 42 | arguments: jib 43 | env: 44 | USERNAME: ${{ github.actor }} 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} 47 | DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} 48 | 49 | - name: Build Native Image 50 | uses: gradle/actions/setup-gradle@v3 51 | if: startsWith(github.ref, 'refs/tags/v') 52 | with: 53 | gradle-version: 8.7 54 | arguments: "bootBuildImage --publishImage" 55 | env: 56 | USERNAME: ${{ github.actor }} 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} 59 | DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | 39 | # Local 40 | application-local.yml 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rarrnomore - prevent automatic grabs of rar'd scene releases 2 | 3 | Disclaimer: I am not responsible for you deleting any torrents. Please test your setup before running it unmonitored. 4 | **You need to set your torrent clients in Radarr and Sonarr to start new torrents in the paused state.** 5 | 6 | ### Notes 7 | - currently, only qBittorrent and Transmission are supported 8 | 9 | This application works by monitoring Radarr and Sonarr `Grab` requests through web hooks that you need to set up. 10 | Once it receives a notification, it instantly connects to your torrent client, finds the torrent that was just added and checks its contents. 11 | If it finds a .rar or partial rar (.r01) file, it sends a request to your *arr application to delete this item from the queue and blocklist that torrent. 12 | If no rar is found, it sends a request to your torrent client to resume the torrent, starting the download. 13 | 14 | ## Setup 15 | Currently, the code is only published as a docker image. If you cannot use Docker, you have to compile it yourself an [executable jar for Spring Boot](https://docs.spring.io/spring-boot/docs/current/reference/html/executable-jar.html). 16 | 17 | ### Setting up Docker 18 | - map /config from within the container to a host folder of your choice 19 | - within that host folder, put a copy of [application.yml](https://github.com/Schaka/rarrnomore/blob/main/src/main/resources/application.yml) from this repository 20 | - choose either `QBITTORRENT` or `TRANSMISSION` (credentials not required for Transmission, if disabled) 21 | - adjust said copy with your own info like *arr API keys and your preferred port 22 | - forward the port you've chosen from your container to the host system 23 | 24 | **Important**: The `clients.torrent.name` property needs to exactly match the name you gave your client in Sonarr/Radarr, this is validated against web hook requests at runtime. 25 | 26 | A docker run command may look like this: 27 | ``` 28 | docker run 29 | -d 30 | --name='rarrnomore' 31 | -e HOST_CONTAINERNAME="rarrnomore" 32 | -p 8978:8978 33 | -v '/mnt/user/appdata/rarrnomore/config':'/config':'rw' 'ghcr.io/schaka/rarrnomore' 34 | ``` 35 | 36 | An example of a `docker-compose.yml` may look like this: 37 | 38 | ```yml 39 | services: 40 | janitorr: 41 | container_name: rarrnomore 42 | image: ghcr.io/schaka/rarrnomore:latest 43 | volumes: 44 | - /appdata/janitorr/config:/config 45 | ``` 46 | 47 | A native image is also published for every tagged release. It keeps a much lower memory and CPU footprint and doesn't require longer runtimes to achieve optimal performance (JIT). 48 | If you restart more often than once a week or have a very low powered server, this is now recommended. 49 | That image is always tagged `:native`. To get a specific version, use `:native-v1.x.x`. 50 | It also requires you to map application.yml slightly differently - see below: 51 | 52 | ```yml 53 | services: 54 | janitorr: 55 | container_name: janitorr 56 | image: ghcr.io/schaka/rarrnomore:native 57 | volumes: 58 | - /appdata/rarrnomore/config/application.yml:/workspace/application.yml 59 | ``` 60 | 61 | To get the latest build as found in the development branch, grab the following image: `ghcr.io/schaka/rarrnomore:develop`. 62 | 63 | ### Setting up Unraid 64 | - Go to Docker, click "Add Container" at the bottom 65 | - enter image name 'schaka/rarrnomore' 66 | - Click "Add another Path, Port, Variable, Label or Device", choose Path 67 | - map Container Path `/config` to host path `/mnt/user/appdata/rarrnomore/config` 68 | - for native, map Container Path `/app/application.yml` to host path `/mnt/user/appdata/rarrnomore/config/application.yml` 69 | - map Container Port `8978` to host port `8978` 70 | 71 | It should look like this: 72 | 73 | ![unraid](docs/img/unraid.png) 74 | 75 | ## Configuring your web hook 76 | - open Sonarr/Radarr, go to Settings => Connect 77 | - click '+', choose Webhook, choose a name 78 | - only enable Notification trigger 'Grab' 79 | - enter `http://rarrnomore:8978/hook/sonarr`, where IP and port need to match your Docker container 80 | - replace `sonarr` with `radarr` if applicable 81 | - choose method POST, save the settings 82 | 83 | It should look like this: 84 | 85 | ![webhook](docs/img/webhook.png) 86 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.google.cloud.tools.jib.api.buildplan.ImageFormat 2 | import org.gradle.plugins.ide.idea.model.IdeaModel 3 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 4 | import net.nemerosa.versioning.VersioningExtension 5 | import org.springframework.boot.gradle.dsl.SpringBootExtension 6 | import org.springframework.boot.gradle.tasks.aot.ProcessAot 7 | import org.springframework.boot.gradle.tasks.bundling.BootBuildImage 8 | import org.springframework.boot.gradle.tasks.run.BootRun 9 | 10 | plugins { 11 | 12 | id("idea") 13 | id("org.springframework.boot") version "3.2.4" 14 | id("io.spring.dependency-management") version "1.1.4" 15 | id("com.google.cloud.tools.jib") version "3.4.2" 16 | id("net.nemerosa.versioning") version "2.8.2" 17 | id("org.graalvm.buildtools.native") version "0.10.1" 18 | 19 | kotlin("jvm") version "1.9.23" 20 | kotlin("plugin.spring") version "1.9.23" 21 | 22 | } 23 | 24 | repositories { 25 | gradlePluginPortal() 26 | mavenCentral() 27 | } 28 | 29 | dependencies { 30 | implementation("org.springframework.boot:spring-boot-starter-web") 31 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin") 32 | implementation("org.jetbrains.kotlin:kotlin-reflect") 33 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0") 34 | 35 | developmentOnly("org.springframework.boot:spring-boot-devtools") 36 | 37 | implementation("org.slf4j:jcl-over-slf4j") 38 | 39 | testImplementation(kotlin("test")) 40 | testImplementation("io.mockk:mockk:1.13.9") 41 | testImplementation("org.springframework.boot:spring-boot-starter-test") { 42 | exclude(module = "mockito-core") 43 | } 44 | 45 | annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") 46 | } 47 | 48 | configure { 49 | buildInfo() 50 | } 51 | 52 | configure { 53 | module { 54 | inheritOutputDirs = true 55 | } 56 | } 57 | 58 | kotlin { 59 | jvmToolchain { 60 | languageVersion.set(JavaLanguageVersion.of(21)) 61 | vendor.set(JvmVendorSpec.ADOPTIUM) 62 | } 63 | } 64 | 65 | tasks.withType { 66 | useJUnitPlatform() 67 | } 68 | 69 | tasks.withType { 70 | sourceCompatibility = JavaVersion.VERSION_21.toString() 71 | targetCompatibility = JavaVersion.VERSION_21.toString() 72 | } 73 | 74 | tasks.withType { 75 | kotlinOptions { 76 | freeCompilerArgs = listOf("-Xjsr305=strict") 77 | jvmTarget = JavaVersion.VERSION_21.toString() 78 | } 79 | } 80 | 81 | configure { 82 | /** 83 | * Add GitHub CI branch name environment variable 84 | */ 85 | branchEnv = listOf("GITHUB_REF_NAME") 86 | } 87 | 88 | extra { 89 | val build = getBuild() 90 | val versioning: VersioningExtension = extensions.getByName("versioning") 91 | val branch = versioning.info.branch 92 | val shortCommit = versioning.info.commit.take(8) 93 | 94 | project.extra["build.date-time"] = build.buildDateAndTime 95 | project.extra["build.date"] = build.formattedBuildDate() 96 | project.extra["build.time"] = build.formattedBuildTime() 97 | project.extra["build.revision"] = versioning.info.commit 98 | project.extra["build.revision.abbreviated"] = shortCommit 99 | project.extra["build.branch"] = branch 100 | project.extra["build.user"] = build.userName() 101 | 102 | val containerImageName = "schaka/${project.name}" 103 | val containerImageTags = mutableSetOf(shortCommit, branch) 104 | if (branch == "main") { 105 | containerImageTags.add("latest") 106 | } 107 | 108 | project.extra["docker.image.name"] = containerImageName 109 | project.extra["docker.image.version"] = branch 110 | project.extra["docker.image.source"] = build.projectSourceRoot() 111 | project.extra["docker.image.tags"] = containerImageTags 112 | 113 | } 114 | 115 | 116 | tasks.withType { 117 | jvmArgs( 118 | arrayOf( 119 | "-Dspring.config.additional-location=optional:file:/config/application.yaml" 120 | ) 121 | ) 122 | } 123 | 124 | tasks.withType { 125 | args("-Dspring.config.additional-location=optional:file:/config/application.yaml") 126 | } 127 | 128 | tasks.withType { 129 | 130 | docker.publishRegistry.url = "ghcr.io" 131 | docker.publishRegistry.username = System.getenv("USERNAME") ?: "INVALID_USER" 132 | docker.publishRegistry.password = System.getenv("GITHUB_TOKEN") ?: "INVALID_PASSWORD" 133 | // docker.publishRegistry.token = System.getenv("GITHUB_TOKEN") ?: "INVALID_TOKEN" 134 | 135 | imageName = "ghcr.io/${project.extra["docker.image.name"]}:native" 136 | version = project.extra["docker.image.version"] as String 137 | createdDate = "now" 138 | tags = listOf( 139 | "ghcr.io/${project.extra["docker.image.name"]}:native", 140 | "ghcr.io/${project.extra["docker.image.name"]}:native-${project.extra["docker.image.version"]}" 141 | ) 142 | 143 | } 144 | 145 | jib { 146 | to { 147 | image = "ghcr.io/${project.extra["docker.image.name"]}" 148 | tags = project.extra["docker.image.tags"] as Set 149 | 150 | auth { 151 | username = System.getenv("USERNAME") 152 | password = System.getenv("GITHUB_TOKEN") 153 | } 154 | } 155 | from { 156 | image = "eclipse-temurin:21-jre-jammy" 157 | auth { 158 | username = System.getenv("DOCKERHUB_USER") 159 | password = System.getenv("DOCKERHUB_PASSWORD") 160 | } 161 | platforms { 162 | platform { 163 | architecture = "amd64" 164 | os = "linux" 165 | } 166 | platform { 167 | architecture = "arm64" 168 | os = "linux" 169 | } 170 | } 171 | } 172 | container { 173 | jvmFlags = listOf("-Dspring.config.additional-location=optional:file:/config/application.yaml", "-Xms256m") 174 | mainClass = "com.github.schaka.rarrnomore.RarrnomoreApplicationKt" 175 | ports = listOf("8978") 176 | format = ImageFormat.Docker 177 | volumes = listOf("/config") 178 | 179 | labels.set( 180 | mapOf( 181 | "org.opencontainers.image.created" to "${project.extra["build.date"]}T${project.extra["build.time"]}", 182 | "org.opencontainers.image.revision" to project.extra["build.revision"] as String, 183 | "org.opencontainers.image.version" to project.version as String, 184 | "org.opencontainers.image.title" to project.name, 185 | "org.opencontainers.image.authors" to "Schaka ", 186 | "org.opencontainers.image.source" to project.extra["docker.image.source"] as String, 187 | "org.opencontainers.image.description" to project.description, 188 | ) 189 | ) 190 | 191 | 192 | // Exclude all "developmentOnly" dependencies, e.g. Spring devtools. 193 | configurationName.set("productionRuntimeClasspath") 194 | } 195 | } -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | group = "com.github.schaka.rarrnomore" 4 | 5 | plugins { 6 | `kotlin-dsl` 7 | } 8 | 9 | repositories { 10 | mavenCentral() 11 | } 12 | 13 | tasks.withType { 14 | sourceCompatibility = JavaVersion.VERSION_18.toString() 15 | targetCompatibility = JavaVersion.VERSION_18.toString() 16 | } 17 | 18 | tasks.withType { 19 | kotlinOptions { 20 | freeCompilerArgs = listOf("-Xjsr305=strict") 21 | jvmTarget = JavaVersion.VERSION_18.toString() 22 | } 23 | } -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/com.github.schaka.build/Build.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.build 2 | 3 | import org.gradle.api.Project 4 | import java.lang.System.getenv 5 | import java.time.OffsetDateTime 6 | import java.time.format.DateTimeFormatter 7 | 8 | class Build(private val project: Project) { 9 | 10 | /** 11 | * The current build time. 12 | */ 13 | val buildDateAndTime: OffsetDateTime = OffsetDateTime.now() 14 | 15 | /** 16 | * @return true, if it is a build on a CI system. 17 | */ 18 | fun isCI(): Boolean { 19 | return getenv("CI") != null 20 | } 21 | 22 | /** 23 | * @return true, if it is a merge request pipeline. 24 | */ 25 | fun isMergeRequest(): Boolean { 26 | 27 | val ciPipelineSource = getenv("CI_PIPELINE_SOURCE") 28 | 29 | return isCI() 30 | && ciPipelineSource != null 31 | && ciPipelineSource == "merge_request_event" 32 | } 33 | 34 | fun mergeRequestId(): String { 35 | return getenv("CI_MERGE_REQUEST_IID") 36 | } 37 | 38 | fun mergeRequestSourceBranch(): String { 39 | return getenv("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME") 40 | } 41 | 42 | fun mergeRequestTargetBranch(): String { 43 | return getenv("CI_MERGE_REQUEST_TARGET_BRANCH_NAME") 44 | } 45 | 46 | fun branchName(): String { 47 | return getenv("GITHUB_REF_NAME") 48 | } 49 | 50 | fun commitHash(): String { 51 | return getenv("GITHUB_SHA") ?: "local" 52 | } 53 | 54 | /** 55 | * @return the username to push containers to the project’s GitLab Container Registry. 56 | * 57 | * Only available if the Container Registry is enabled for the project. 58 | * 59 | * If the variable is not set, an empty string is used. 60 | */ 61 | fun containerRegistryUser(): String { 62 | return getenv("DOCKERHUB_USER") ?: "" 63 | } 64 | 65 | /** 66 | * @return The password to push containers to the project’s GitLab Container Registry. 67 | * 68 | * Only available if the Container Registry is enabled for the project. 69 | * 70 | * This password value is the same as the `CI_JOB_TOKEN` and is valid only as long as the job is running. 71 | * 72 | * If the variable is not set, an empty string is used. 73 | */ 74 | fun containerRegistryPassword(): String { 75 | return getenv("DOCKERHUB_PASSWORD") ?: "" 76 | } 77 | 78 | /** 79 | * @return the container image name to be used as FROM image for the application image. 80 | */ 81 | fun containerBaseImage(): String { 82 | return project.property("containerBaseImage") as String 83 | } 84 | 85 | /** 86 | * @return the exposed ports by the container. 87 | */ 88 | fun containerPorts(): List { 89 | val property = project.property("containerPorts") as String 90 | return property.split(";").map { it.trim() } 91 | } 92 | 93 | /** 94 | * @return the flags for the JVM at runtime. 95 | */ 96 | fun containerJvmFlags(): List { 97 | val property = project.property("containerJvmFlags") as String 98 | return property.split(";").map { it.trim() } 99 | } 100 | 101 | /** 102 | * @return the HTTP(S) address of the project. 103 | * 104 | * If the variable is not set, an empty string is used. 105 | */ 106 | fun projectSourceRoot(): String { 107 | return getenv("CI_PROJECT_URL") ?: "${project.rootDir}" 108 | } 109 | 110 | /** 111 | * @return the name of the user who started the job. 112 | * 113 | * If the variable is not set, the local user name is used. 114 | */ 115 | fun userName(): String { 116 | return getenv("GITLAB_USER_NAME") ?: System.getProperty("user.name") 117 | } 118 | 119 | /** 120 | * @return a token to authenticate with certain API endpoints. The token is valid as long as the job is running. 121 | */ 122 | fun jobToken(): String? { 123 | return getenv("GITHUB_TOKEN") 124 | } 125 | 126 | /** 127 | * @return the current build date in ISO format. 128 | */ 129 | fun formattedBuildDate(): String { 130 | return DateTimeFormatter.ISO_LOCAL_DATE.format(buildDateAndTime) 131 | } 132 | 133 | /** 134 | * @return the current build time without date in ISO format. 135 | */ 136 | fun formattedBuildTime(): String { 137 | return DateTimeFormatter.ofPattern("HH:mm:ss.SSSZ").format(buildDateAndTime) 138 | } 139 | } -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/project-extensions.kt: -------------------------------------------------------------------------------- 1 | import com.github.schaka.build.Build 2 | import org.gradle.api.DefaultTask 3 | import org.gradle.api.Project 4 | import org.gradle.api.tasks.SourceSetContainer 5 | import org.gradle.api.tasks.TaskProvider 6 | import org.gradle.kotlin.dsl.extra 7 | import org.gradle.kotlin.dsl.getByType 8 | 9 | /** 10 | * Returns a CSV list of absolute paths located in the all subproject report directories. 11 | * 12 | * @param include a include pattern. 13 | * @return a CSV list with absolute paths. 14 | */ 15 | fun Project.collectSubprojectsReportFiles(include: String): String { 16 | return subprojects 17 | .joinToString(",") { subproject: Project -> 18 | subproject.collectReportFiles(include) 19 | } 20 | } 21 | 22 | /** 23 | * Returns a CSV list of absolute paths of all subproject main source directories. 24 | * 25 | * @return a CSV list with absolute paths. 26 | */ 27 | fun Project.collectSubprojectsSourceDirectories(sourceSetName: String): String { 28 | return subprojects 29 | .map { it.extensions.getByName("sourceSets") as SourceSetContainer } 30 | .map { it.getByName(sourceSetName) } 31 | .flatMap { it.java.srcDirs } 32 | .joinToString(",") 33 | } 34 | 35 | /** 36 | * Returns a CSV list of absolute paths located in the project report directory. 37 | * 38 | * @param include a include pattern. 39 | * @return a CSV list with absolute paths. 40 | */ 41 | fun Project.collectReportFiles(include: String): String { 42 | val fileTree = fileTree("${buildDir}/reports") { 43 | include(include) 44 | } 45 | 46 | return fileTree 47 | .files 48 | .joinToString(",") 49 | } 50 | 51 | /** 52 | * Returns a list with the check tasks of the subprojects. 53 | */ 54 | fun Project.subprojectTasks(taskName: String): List> { 55 | return subprojects 56 | .map { subproject: Project -> 57 | subproject.tasks.named(taskName, org.gradle.api.DefaultTask::class.java) 58 | } 59 | } 60 | 61 | /** 62 | * @return the SourceSetContainer of the project. 63 | */ 64 | fun Project.getSourceSetContainer(): SourceSetContainer { 65 | return extensions.getByType(SourceSetContainer::class) 66 | } 67 | 68 | /** 69 | * @return the special information of this build. 70 | */ 71 | fun Project.getBuild(): Build { 72 | val build: Build 73 | if (extra.has(Build::class.java.name)) { 74 | build = extra.get(Build::class.java.name) as Build 75 | } else { 76 | build = Build(this) 77 | extra.set(Build::class.java.name, build) 78 | } 79 | return build 80 | } -------------------------------------------------------------------------------- /docs/img/unraid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Schaka/rarrnomore/886cef631742de43459c3d279f46ac84ec1661f2/docs/img/unraid.png -------------------------------------------------------------------------------- /docs/img/webhook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Schaka/rarrnomore/886cef631742de43459c3d279f46ac84ec1661f2/docs/img/webhook.png -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | group = com.github.schaka.rarrnomore 2 | version = 1.1.0 3 | description = Prevents automatic grabs of rar'd scene releases. -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Schaka/rarrnomore/886cef631742de43459c3d279f46ac84ec1661f2/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /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/master/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 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || 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 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Stop when "xargs" is not available. 209 | if ! command -v xargs >/dev/null 2>&1 210 | then 211 | die "xargs is not available" 212 | fi 213 | 214 | # Use "xargs" to parse quoted args. 215 | # 216 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 217 | # 218 | # In Bash we could simply go: 219 | # 220 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 221 | # set -- "${ARGS[@]}" "$@" 222 | # 223 | # but POSIX shell has neither arrays nor command substitution, so instead we 224 | # post-process each arg (as a line of input to sed) to backslash-escape any 225 | # character that might be a shell metacharacter, then use eval to reverse 226 | # that process (while maintaining the separation between arguments), and wrap 227 | # the whole thing up as a single "set" statement. 228 | # 229 | # This will of course break if any of these variables contains a newline or 230 | # an unmatched quote. 231 | # 232 | 233 | eval "set -- $( 234 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 235 | xargs -n1 | 236 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 237 | tr '\n' ' ' 238 | )" '"$@"' 239 | 240 | exec "$JAVACMD" "$@" 241 | -------------------------------------------------------------------------------- /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 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if %ERRORLEVEL% equ 0 goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if %ERRORLEVEL% equ 0 goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | set EXIT_CODE=%ERRORLEVEL% 84 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 85 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 86 | exit /b %EXIT_CODE% 87 | 88 | :mainEnd 89 | if "%OS%"=="Windows_NT" endlocal 90 | 91 | :omega 92 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "rarrnomore" 2 | 3 | pluginManagement { 4 | repositories { 5 | gradlePluginPortal() 6 | mavenCentral() 7 | } 8 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/RarrnomoreApplication.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore 2 | 3 | import com.github.schaka.rarrnomore.servarr.ServarrService 4 | import org.springframework.aot.hint.RuntimeHints 5 | import org.springframework.aot.hint.RuntimeHintsRegistrar 6 | import org.springframework.boot.autoconfigure.SpringBootApplication 7 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan 8 | import org.springframework.boot.context.properties.EnableConfigurationProperties 9 | import org.springframework.boot.runApplication 10 | import org.springframework.context.annotation.ImportRuntimeHints 11 | import org.springframework.scheduling.annotation.EnableAsync 12 | import org.springframework.scheduling.annotation.EnableScheduling 13 | 14 | @EnableConfigurationProperties 15 | @EnableAsync 16 | @EnableScheduling 17 | @ConfigurationPropertiesScan 18 | @SpringBootApplication 19 | @ImportRuntimeHints(RarrnomoreApplication.Hints::class) 20 | class RarrnomoreApplication { 21 | 22 | class Hints : RuntimeHintsRegistrar { 23 | override fun registerHints(hints: RuntimeHints, classLoader: ClassLoader?) { 24 | hints.proxies().registerJdkProxy(ServarrService::class.java) 25 | } 26 | } 27 | 28 | } 29 | 30 | fun main(args: Array) { 31 | runApplication(*args) 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/hooks/HookController.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.hooks 2 | 3 | import com.github.schaka.rarrnomore.hooks.radarr.RadarrWebHookRequest 4 | import com.github.schaka.rarrnomore.hooks.sonarr.SonarrWebHookRequest 5 | import com.github.schaka.rarrnomore.servarr.radarr.RadarrService 6 | import com.github.schaka.rarrnomore.servarr.sonarr.SonarrService 7 | import com.github.schaka.rarrnomore.torrent.TorrentClientType 8 | import com.github.schaka.rarrnomore.torrent.TorrentManager 9 | import com.github.schaka.rarrnomore.torrent.TorrentService 10 | import com.github.schaka.rarrnomore.torrent.rest.TorrentClientProperties 11 | import kotlinx.coroutines.GlobalScope 12 | import kotlinx.coroutines.delay 13 | import kotlinx.coroutines.launch 14 | import kotlinx.coroutines.runBlocking 15 | import org.slf4j.LoggerFactory 16 | import org.springframework.http.ResponseEntity 17 | import org.springframework.stereotype.Controller 18 | import org.springframework.web.bind.annotation.GetMapping 19 | import org.springframework.web.bind.annotation.PostMapping 20 | import org.springframework.web.bind.annotation.RequestBody 21 | import org.springframework.web.bind.annotation.RequestMapping 22 | 23 | @Controller 24 | @RequestMapping("/hook") 25 | class HookController( 26 | val sonarrService: SonarrService, 27 | val radarrService: RadarrService, 28 | val torrentManager: TorrentManager, 29 | val torrentClientProperties: TorrentClientProperties 30 | ) { 31 | 32 | companion object { 33 | private val log = LoggerFactory.getLogger(this::class.java.enclosingClass) 34 | } 35 | 36 | @PostMapping("/sonarr") 37 | fun sonarr(@RequestBody request: SonarrWebHookRequest): ResponseEntity { 38 | 39 | if (!validateRequest(request)) { 40 | return ResponseEntity.noContent().build() 41 | } 42 | 43 | log.trace("{}", request) 44 | torrentManager.processGrab(toTorrentInfo(request), sonarrService) 45 | 46 | return ResponseEntity.noContent().build() 47 | } 48 | 49 | @PostMapping("/radarr") 50 | fun radarr(@RequestBody request: RadarrWebHookRequest): ResponseEntity { 51 | 52 | if (!validateRequest(request)) { 53 | return ResponseEntity.noContent().build() 54 | } 55 | 56 | log.trace("{}", request) 57 | torrentManager.processGrab(toTorrentInfo(request), radarrService) 58 | 59 | return ResponseEntity.noContent().build() 60 | } 61 | 62 | fun validateRequest(request: WebHookRequest): Boolean { 63 | 64 | if (request.eventType != "Grab") { 65 | log.debug("Received request not applicable for event type 'Grab': {}", request) 66 | return false 67 | } 68 | 69 | if (request.hash == null) { 70 | log.warn("Received test request or request without hash: {}", request) 71 | return false 72 | } 73 | 74 | if (torrentClientProperties.type.servarrName != request.downloadClientType) { 75 | log.warn("Client type doesn't match - expected: {} - request: {}", torrentClientProperties.type.servarrName, request) 76 | return false 77 | } 78 | 79 | if (torrentClientProperties.name != request.downloadClient) { 80 | log.warn("Client name doesn't match - expected: {} - request: {}", torrentClientProperties.name, request) 81 | return false 82 | } 83 | 84 | return true 85 | } 86 | 87 | fun toTorrentInfo(request: SonarrWebHookRequest): TorrentInfo { 88 | return TorrentInfo( 89 | request.hash?.lowercase()!!, 90 | request.downloadClientType!!, 91 | request.downloadClient!!, 92 | request.release!!.indexer, 93 | request.release!!.releaseTitle 94 | ) 95 | } 96 | 97 | fun toTorrentInfo(request: RadarrWebHookRequest): TorrentInfo { 98 | return TorrentInfo( 99 | request.hash?.lowercase()!!, 100 | request.downloadClientType!!, 101 | request.downloadClient!!, 102 | request.release!!.indexer, 103 | request.release!!.releaseTitle 104 | ) 105 | } 106 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/hooks/TorrentInfo.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.hooks 2 | 3 | class TorrentInfo( 4 | val hash: String, 5 | val downloadClientType: String, 6 | val downloadClient: String, 7 | val indexer: String, 8 | val torrentName: String 9 | ) { 10 | 11 | val filenames: MutableList = mutableListOf() 12 | 13 | fun addFiles(files: List) { 14 | this.filenames.addAll(files) 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/hooks/WebHookRequest.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.hooks 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty 4 | 5 | open class WebHookRequest( 6 | open var eventType: String, 7 | // if you grab something manually (e.g. through Interactive Search) and Sonarr can't automatically map it to a show, the below properties aren't available 8 | open var downloadClientType: String?, 9 | open var downloadClient: String?, 10 | @JsonProperty("downloadId") 11 | open var hash: String?, 12 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/hooks/radarr/RadarrMovie.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.hooks.radarr 2 | 3 | data class RadarrMovie( 4 | var id: Int, 5 | var title: String, 6 | var folderPath: String 7 | 8 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/hooks/radarr/RadarrRelease.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.hooks.radarr 2 | 3 | data class RadarrRelease( 4 | var indexer: String, 5 | var releaseTitle: String 6 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/hooks/radarr/RadarrWebHookRequest.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.hooks.radarr 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty 4 | import com.github.schaka.rarrnomore.hooks.WebHookRequest 5 | 6 | data class RadarrWebHookRequest( 7 | var movie: RadarrMovie, 8 | override var eventType: String, 9 | 10 | // if you grab something manually (e.g. through Interactive Search) and Radarr can't automatically map it to a show, the below properties aren't available 11 | var release: RadarrRelease?, 12 | override var downloadClientType: String?, 13 | override var downloadClient: String?, 14 | @JsonProperty("downloadId") 15 | override var hash: String?, 16 | ) : WebHookRequest(eventType, downloadClientType, downloadClient, hash) -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/hooks/sonarr/SonarrRelease.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.hooks.sonarr 2 | 3 | data class SonarrRelease( 4 | var indexer: String, 5 | var releaseTitle: String 6 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/hooks/sonarr/SonarrSeries.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.hooks.sonarr 2 | 3 | data class SonarrSeries( 4 | var id: Int, 5 | var title: String, 6 | var path: String 7 | 8 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/hooks/sonarr/SonarrWebHookRequest.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.hooks.sonarr 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty 4 | import com.github.schaka.rarrnomore.hooks.WebHookRequest 5 | 6 | data class SonarrWebHookRequest( 7 | var series: SonarrSeries, 8 | var episodes: Any, 9 | override var eventType: String, 10 | 11 | // if you grab something manually (e.g. through Interactive Search) and Sonarr can't automatically map it to a show, the below properties aren't available 12 | var release: SonarrRelease?, 13 | override var downloadClientType: String?, 14 | override var downloadClient: String?, 15 | @JsonProperty("downloadId") 16 | override var hash: String?, 17 | ) : WebHookRequest(eventType, downloadClientType, downloadClient, hash) -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/servarr/ServarrClientConfig.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.servarr 2 | 3 | import com.github.schaka.rarrnomore.servarr.radarr.Radarr 4 | import com.github.schaka.rarrnomore.servarr.radarr.RadarrProperties 5 | import com.github.schaka.rarrnomore.servarr.sonarr.Sonarr 6 | import com.github.schaka.rarrnomore.servarr.sonarr.SonarrProperties 7 | import org.slf4j.LoggerFactory 8 | import org.slf4j.LoggerFactory.getLogger 9 | import org.springframework.boot.web.client.RestTemplateBuilder 10 | import org.springframework.context.annotation.Bean 11 | import org.springframework.context.annotation.Configuration 12 | import org.springframework.web.client.RestTemplate 13 | 14 | @Configuration 15 | class ServarrClientConfig { 16 | 17 | companion object { 18 | private val log = LoggerFactory.getLogger(this::class.java.enclosingClass) 19 | } 20 | 21 | @Radarr 22 | @Bean 23 | fun radarrRestTemplate(builder: RestTemplateBuilder, properties: RadarrProperties): RestTemplate { 24 | return builder 25 | .rootUri("${properties.url}/api/v3") 26 | .defaultHeader("X-Api-Key", properties.apiKey) 27 | .build() 28 | } 29 | 30 | @Sonarr 31 | @Bean 32 | fun sonarrRestTemplate(builder: RestTemplateBuilder, properties: SonarrProperties): RestTemplate { 33 | return builder 34 | .rootUri("${properties.url}/api/v3") 35 | .defaultHeader("X-Api-Key", properties.apiKey) 36 | .build() 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/servarr/ServarrService.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.servarr 2 | 3 | import com.github.schaka.rarrnomore.hooks.TorrentInfo 4 | 5 | interface ServarrService { 6 | 7 | @Throws(TorrentNotInQueueException::class) 8 | fun deleteAndBlacklist(info: TorrentInfo) 9 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/servarr/TorrentNotInQueueException.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.servarr 2 | 3 | class TorrentNotInQueueException(override val message: String) : RuntimeException(message) { 4 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/servarr/radarr/Radarr.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.servarr.radarr 2 | 3 | import org.springframework.beans.factory.annotation.Qualifier 4 | 5 | @Target(AnnotationTarget.TYPE, AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) 6 | @Retention(AnnotationRetention.RUNTIME) 7 | @Qualifier("radarr") 8 | annotation class Radarr() 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/servarr/radarr/RadarrProperties.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.servarr.radarr 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties 4 | 5 | @ConfigurationProperties(prefix = "clients.radarr") 6 | data class RadarrProperties( 7 | val url: String, 8 | val apiKey: String 9 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/servarr/radarr/RadarrQueueItem.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.servarr.radarr 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty 4 | 5 | // there are some edge cases where trackers don't respond or autobrr adding a torrent that causes hash, indexer and downloadClient to be null 6 | data class RadarrQueueItem( 7 | val id: Int, 8 | val movieId: Int, 9 | val downloadClient: String?, 10 | @JsonProperty("downloadId") 11 | var hash: String?, 12 | val indexer: String? 13 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/servarr/radarr/RadarrQueueList.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.servarr.radarr 2 | 3 | data class RadarrQueueList( 4 | val page: Int, 5 | val pageSize: Int, 6 | val totalRecords: Int, 7 | val records: List 8 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/servarr/radarr/RadarrService.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.servarr.radarr 2 | 3 | import com.github.schaka.rarrnomore.hooks.TorrentInfo 4 | import com.github.schaka.rarrnomore.servarr.ServarrService 5 | import com.github.schaka.rarrnomore.servarr.TorrentNotInQueueException 6 | import com.github.schaka.rarrnomore.servarr.sonarr.SonarrService 7 | import org.slf4j.LoggerFactory 8 | import org.springframework.aot.hint.annotation.RegisterReflectionForBinding 9 | import org.springframework.stereotype.Service 10 | import org.springframework.web.client.RestTemplate 11 | 12 | @Service 13 | @RegisterReflectionForBinding(classes = [RadarrQueueList::class, RadarrQueueItem::class]) 14 | class RadarrService( 15 | 16 | @Radarr 17 | val client: RestTemplate 18 | 19 | ) : ServarrService { 20 | 21 | companion object { 22 | private val log = LoggerFactory.getLogger(this::class.java.enclosingClass) 23 | } 24 | 25 | override fun deleteAndBlacklist(info: TorrentInfo) { 26 | val queue = client.getForEntity("/queue?includeUnknownSeriesItems=true&pageSize=10000", RadarrQueueList::class.java) 27 | log.trace("Queue items found: {}", queue.body?.records) 28 | val itemToDelete = queue.body?.records?.find { it.hash?.lowercase() == info.hash.lowercase() } 29 | ?: throw TorrentNotInQueueException("Torrent with hash ${info.hash} not found in queue") 30 | 31 | log.info( 32 | "Found torrent {} (id: {}) at indexer {} with rar files - deleting.", 33 | info.torrentName, itemToDelete.id, info.indexer 34 | ) 35 | log.trace("Rar files found in deleted torrent {}: {}", info.torrentName, info.filenames) 36 | 37 | client.delete("/queue/{id}?removeFromClient=true&blocklist=true", itemToDelete.id) 38 | } 39 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/servarr/sonarr/SonarQueueItem.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.servarr.sonarr 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty 4 | 5 | // there are some edge cases where trackers don't respond or autobrr adding a torrent that causes hash, indexer and downloadClient to be null 6 | data class SonarQueueItem( 7 | val id: Int, 8 | val seriesId: Int, 9 | val episodeId: Int, 10 | val seasonNumber: Int, 11 | val downloadClient: String?, 12 | @JsonProperty("downloadId") 13 | var hash: String?, 14 | val indexer: String?, 15 | val outputPath: String? 16 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/servarr/sonarr/Sonarr.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.servarr.sonarr 2 | 3 | import org.springframework.beans.factory.annotation.Qualifier 4 | 5 | @Target(AnnotationTarget.TYPE, AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) 6 | @Retention(AnnotationRetention.RUNTIME) 7 | @Qualifier("sonarr") 8 | annotation class Sonarr() 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/servarr/sonarr/SonarrProperties.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.servarr.sonarr 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties 4 | 5 | @ConfigurationProperties(prefix = "clients.sonarr") 6 | data class SonarrProperties( 7 | val url: String, 8 | val apiKey: String 9 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/servarr/sonarr/SonarrQueueList.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.servarr.sonarr 2 | 3 | data class SonarrQueueList( 4 | val page: Int, 5 | val pageSize: Int, 6 | val totalRecords: Int, 7 | val records: List 8 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/servarr/sonarr/SonarrService.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.servarr.sonarr 2 | 3 | import com.github.schaka.rarrnomore.hooks.TorrentInfo 4 | import com.github.schaka.rarrnomore.servarr.ServarrService 5 | import com.github.schaka.rarrnomore.servarr.TorrentNotInQueueException 6 | import org.slf4j.LoggerFactory 7 | import org.springframework.aot.hint.annotation.RegisterReflectionForBinding 8 | import org.springframework.stereotype.Service 9 | import org.springframework.web.client.RestTemplate 10 | 11 | @Service 12 | @RegisterReflectionForBinding(classes = [SonarrQueueList::class, SonarQueueItem::class]) 13 | class SonarrService( 14 | 15 | @Sonarr 16 | val client: RestTemplate 17 | 18 | ) : ServarrService { 19 | 20 | companion object { 21 | private val log = LoggerFactory.getLogger(this::class.java.enclosingClass) 22 | } 23 | 24 | override fun deleteAndBlacklist(info: TorrentInfo) { 25 | val queue = client.getForEntity("/queue?includeUnknownSeriesItems=true&pageSize=10000", SonarrQueueList::class.java) 26 | log.trace("Queue items found: {}", queue.body?.records) 27 | val itemToDelete = queue.body?.records?.find { it.hash?.lowercase() == info.hash.lowercase() } 28 | ?: throw TorrentNotInQueueException("Torrent with hash ${info.hash} not found in queue") 29 | 30 | log.info( 31 | "Found torrent {} (id: {}) at indexer {} with rar files - deleting.", 32 | info.torrentName, itemToDelete.id, info.indexer 33 | ) 34 | log.trace("Rar files found in deleted torrent {}: {}", info.torrentName, info.filenames) 35 | 36 | client.delete("/queue/{id}?removeFromClient=true&blocklist=true", itemToDelete.id) 37 | } 38 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/TorrentClientType.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent 2 | 3 | enum class TorrentClientType( 4 | val servarrName: String 5 | ) { 6 | QBITTORRENT("qBittorrent"), 7 | TRANSMISSION("Transmission") 8 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/TorrentHashNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent 2 | 3 | class TorrentHashNotFoundException(override val message: String) : RuntimeException(message) { 4 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/TorrentManager.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent 2 | 3 | import com.github.schaka.rarrnomore.hooks.TorrentInfo 4 | import com.github.schaka.rarrnomore.servarr.ServarrService 5 | import com.github.schaka.rarrnomore.servarr.TorrentNotInQueueException 6 | import com.github.schaka.rarrnomore.torrent.rest.TorrentClientProperties 7 | import org.slf4j.LoggerFactory 8 | import org.springframework.scheduling.annotation.Scheduled 9 | import org.springframework.stereotype.Component 10 | import java.time.LocalDateTime 11 | import java.util.concurrent.CopyOnWriteArrayList 12 | 13 | @Component 14 | class TorrentManager( 15 | private var torrentService: TorrentService, 16 | private val torrentClientProperties: TorrentClientProperties, 17 | private val torrentQueue: MutableList = CopyOnWriteArrayList(), 18 | torrentsServiceResolver: TorrentsServiceResolver, 19 | ) { 20 | 21 | companion object { 22 | private val log = LoggerFactory.getLogger(this::class.java.enclosingClass) 23 | } 24 | 25 | init { 26 | torrentService = torrentsServiceResolver.resolve() 27 | } 28 | 29 | fun processGrab(torrentInfo: TorrentInfo, servarrService: ServarrService) { 30 | torrentQueue.add(TorrentQueueItem(torrentInfo, servarrService)) 31 | } 32 | 33 | /** 34 | * Checks the entire torrent to see if any rar files are contained. 35 | * Media torrents needing extraction are terrible and scene releases should not be packed for p2p. 36 | */ 37 | private fun torrentContainsRar(filenames: List): Boolean { 38 | return filenames.any { filename -> filename.endsWith(".rar") || filename.endsWith(".r00") } 39 | } 40 | 41 | @Scheduled(fixedDelay = 5000) 42 | fun processServarrQueue() { 43 | val toBeRemoved = mutableListOf() 44 | 45 | for (queueItem in torrentQueue) { 46 | // if 3 attempts have been made, abandon retries 47 | if (queueItem.attempts.get() >= 3) { 48 | log.error("Processing torrent ${queueItem.torrentInfo.torrentName} failed", queueItem.lastException) 49 | toBeRemoved.add(queueItem) 50 | continue 51 | } 52 | 53 | if (!needToRetry(queueItem)) { 54 | continue 55 | } 56 | 57 | if (tryToProcess(queueItem)) { 58 | toBeRemoved.add(queueItem) 59 | } 60 | } 61 | 62 | torrentQueue.removeAll(toBeRemoved) 63 | } 64 | 65 | private fun needToRetry(queueItem: TorrentQueueItem): Boolean { 66 | return (queueItem.attempts.get() == 0 && queueItem.lastAttempt.plusMinutes(1).isBefore(LocalDateTime.now())) 67 | || 68 | (queueItem.attempts.get() > 0 && queueItem.lastAttempt.plusMinutes(5).isBefore(LocalDateTime.now())) 69 | } 70 | 71 | 72 | /** 73 | * Retries and reports success 74 | */ 75 | private fun tryToProcess(queueItem: TorrentQueueItem): Boolean { 76 | val servarrService = queueItem.servarrService 77 | val torrentInfo = queueItem.torrentInfo 78 | 79 | try { 80 | log.trace("Attempting to reject or resume torrent ({}) ({})", torrentInfo.torrentName, torrentInfo.hash) 81 | rejectOrResumeTorrent(torrentInfo, servarrService) 82 | return true // no exception, success! 83 | } catch (e: TorrentNotInQueueException) { 84 | increment(queueItem, e) 85 | } catch (e: TorrentHashNotFoundException) { 86 | increment(queueItem, e) 87 | } catch (e: Exception) { 88 | log.error("Unexpected exception occurred, do not retry", e) 89 | queueItem.lastException = e 90 | queueItem.attempts.set(3) 91 | } 92 | 93 | return false 94 | } 95 | 96 | private fun increment(queueItem: TorrentQueueItem, exception: Exception) { 97 | queueItem.attempts.addAndGet(1) 98 | queueItem.lastAttempt = LocalDateTime.now() 99 | queueItem.lastException = exception 100 | } 101 | 102 | private fun rejectOrResumeTorrent(info: TorrentInfo, servarrService: ServarrService) { 103 | val info = torrentService.enrichTorrentInfo(info) 104 | 105 | if (torrentContainsRar(info.filenames)) { 106 | // reject in Sonarr queue and delete 107 | servarrService.deleteAndBlacklist(info) 108 | return 109 | } 110 | 111 | if (torrentClientProperties.autoResume) { 112 | log.info("Torrent (${info.torrentName}) didn't contain rar files - resuming!") 113 | torrentService.resumeTorrent(info.hash) 114 | } 115 | } 116 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/TorrentQueueItem.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent 2 | 3 | import com.github.schaka.rarrnomore.hooks.TorrentInfo 4 | import com.github.schaka.rarrnomore.servarr.ServarrService 5 | import java.time.LocalDateTime 6 | import java.util.concurrent.atomic.AtomicInteger 7 | 8 | class TorrentQueueItem( 9 | val torrentInfo: TorrentInfo, 10 | val servarrService: ServarrService, 11 | var lastAttempt: LocalDateTime = LocalDateTime.now(), 12 | val attempts: AtomicInteger = AtomicInteger(0), 13 | var lastException: Exception? = null 14 | ) { 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/TorrentService.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent 2 | 3 | import com.github.schaka.rarrnomore.hooks.TorrentInfo 4 | 5 | interface TorrentService { 6 | 7 | /** 8 | * Checks the torrent's contents and returns filenames 9 | */ 10 | @Throws(TorrentHashNotFoundException::class) 11 | fun enrichTorrentInfo(info: TorrentInfo): TorrentInfo 12 | 13 | fun resumeTorrent(hash: String) 14 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/TorrentsServiceResolver.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent 2 | 3 | import com.github.schaka.rarrnomore.torrent.qbit.QBittorrentService 4 | import com.github.schaka.rarrnomore.torrent.rest.TorrentClientProperties 5 | import com.github.schaka.rarrnomore.torrent.transmission.TransmissionService 6 | import org.springframework.stereotype.Component 7 | import java.lang.IllegalStateException 8 | 9 | @Component 10 | class TorrentsServiceResolver( 11 | private val qBittorrentService: QBittorrentService?, 12 | private val transmissionService: TransmissionService?, 13 | private val torrentClientProperties: TorrentClientProperties 14 | ) { 15 | 16 | fun resolve(): TorrentService { 17 | return when(torrentClientProperties.type) { 18 | TorrentClientType.QBITTORRENT -> qBittorrentService ?: throw IllegalStateException("Properties for qbittorrent not set correctly") 19 | TorrentClientType.TRANSMISSION -> transmissionService ?: throw IllegalStateException("Properties for transmission not set correctly") 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/qbit/QBittorrent.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent.qbit 2 | 3 | import org.springframework.beans.factory.annotation.Qualifier 4 | 5 | @Target(AnnotationTarget.TYPE, AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.FUNCTION) 6 | @Retention(AnnotationRetention.RUNTIME) 7 | @Qualifier("qbittorrent") 8 | annotation class QBittorrent() 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/qbit/QBittorrentService.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent.qbit 2 | 3 | import com.github.schaka.rarrnomore.hooks.TorrentInfo 4 | import com.github.schaka.rarrnomore.torrent.TorrentHashNotFoundException 5 | import com.github.schaka.rarrnomore.torrent.TorrentService 6 | import org.slf4j.LoggerFactory 7 | import org.springframework.aot.hint.annotation.RegisterReflectionForBinding 8 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty 9 | import org.springframework.core.ParameterizedTypeReference 10 | import org.springframework.http.HttpMethod 11 | import org.springframework.stereotype.Service 12 | import org.springframework.util.LinkedMultiValueMap 13 | import org.springframework.web.client.RestTemplate 14 | 15 | @ConditionalOnProperty("clients.torrent.type", havingValue = "QBITTORRENT") 16 | @RegisterReflectionForBinding(classes = [LinkedMultiValueMap::class, QbitFileResponse::class]) 17 | @Service 18 | class QBittorrentService( 19 | @QBittorrent 20 | private var client: RestTemplate 21 | ) : TorrentService { 22 | 23 | companion object { 24 | private val log = LoggerFactory.getLogger(this::class.java.enclosingClass) 25 | } 26 | 27 | override fun enrichTorrentInfo(info: TorrentInfo): TorrentInfo { 28 | val files = client.exchange( 29 | "/torrents/files?hash={hash}", 30 | HttpMethod.GET, 31 | null, 32 | object : ParameterizedTypeReference>() {}, 33 | info.hash.lowercase() 34 | ) 35 | 36 | if (files.body.isNullOrEmpty()) { 37 | throw TorrentHashNotFoundException("Torrent (${info.torrentName}) (${info.hash}) not in torrent client or files cannot be read") 38 | } 39 | 40 | info.addFiles(files.body!!.map(QbitFileResponse::name)) 41 | log.info( 42 | "Found torrent {} (hash: {}) at indexer {} with files ({}).", 43 | info.torrentName, info.hash, info.indexer, info.filenames 44 | ) 45 | return info 46 | } 47 | 48 | override fun resumeTorrent(hash: String) { 49 | val map = LinkedMultiValueMap() 50 | map.add("hashes", hash) 51 | client.postForEntity( 52 | "/torrents/resume", 53 | map, 54 | String::class.java 55 | ) 56 | } 57 | 58 | 59 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/qbit/QbitAuthInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent.qbit 2 | 3 | import com.github.schaka.rarrnomore.torrent.rest.TorrentClientProperties 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.http.HttpEntity 6 | import org.springframework.http.HttpHeaders 7 | import org.springframework.http.HttpRequest 8 | import org.springframework.http.client.ClientHttpRequestExecution 9 | import org.springframework.http.client.ClientHttpRequestInterceptor 10 | import org.springframework.http.client.ClientHttpResponse 11 | import org.springframework.util.LinkedMultiValueMap 12 | import org.springframework.web.client.RestTemplate 13 | import java.lang.Exception 14 | import java.time.LocalDateTime 15 | 16 | class QbitAuthInterceptor( 17 | val properties: TorrentClientProperties, 18 | var lastLogin: LocalDateTime = LocalDateTime.MIN, 19 | var lastCookie: String = "" 20 | ) : ClientHttpRequestInterceptor { 21 | 22 | companion object { 23 | private val log = LoggerFactory.getLogger(this::class.java.enclosingClass) 24 | } 25 | 26 | override fun intercept( 27 | request: HttpRequest, 28 | body: ByteArray, 29 | execution: ClientHttpRequestExecution 30 | ): ClientHttpResponse { 31 | request.headers[HttpHeaders.COOKIE] = attemptAuthentication() 32 | return execution.execute(request, body) 33 | } 34 | 35 | private fun attemptAuthentication(): String { 36 | 37 | if (lastLogin.plusMinutes(50).isAfter(LocalDateTime.now())) { 38 | // no login required 39 | return lastCookie 40 | } 41 | 42 | try { 43 | val login = RestTemplate() 44 | val map = LinkedMultiValueMap() 45 | map.add("username", properties.username) 46 | map.add("password", properties.password) 47 | val loginID = 48 | login.postForEntity("${properties.url}/api/v2/auth/login", HttpEntity(map), String::class.java) 49 | val cookieHeader = loginID.headers[HttpHeaders.SET_COOKIE]?.find { s -> s.contains("SID") }!! 50 | lastLogin = LocalDateTime.now() 51 | lastCookie = cookieHeader 52 | return cookieHeader 53 | } catch (e: Exception) { 54 | log.error("Error connecting to torrent client", e) 55 | } 56 | 57 | throw IllegalStateException("Can't connect to QBittorrent"); 58 | } 59 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/qbit/QbitFileResponse.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent.qbit 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty 4 | 5 | data class QbitFileResponse( 6 | val availability: Float, 7 | val index: Int, 8 | @JsonProperty("is_seed") 9 | val isSeeding: Boolean, 10 | val name: String, 11 | val priority: Long, 12 | val progress: Float, 13 | val size: Long 14 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/rest/TorrentClientConfig.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent.rest 2 | 3 | import com.github.schaka.rarrnomore.torrent.qbit.QBittorrent 4 | import com.github.schaka.rarrnomore.torrent.qbit.QbitAuthInterceptor 5 | import com.github.schaka.rarrnomore.torrent.transmission.Transmission 6 | import com.github.schaka.rarrnomore.torrent.transmission.TransmissionAuthHandler 7 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty 8 | import org.springframework.boot.web.client.RestTemplateBuilder 9 | import org.springframework.context.annotation.Bean 10 | import org.springframework.context.annotation.Configuration 11 | import org.springframework.web.client.RestTemplate 12 | 13 | @Configuration 14 | class TorrentClientConfig { 15 | 16 | @ConditionalOnProperty("clients.torrent.type", havingValue = "QBITTORRENT") 17 | @QBittorrent 18 | @Bean 19 | fun qBittorrentTemplate(builder: RestTemplateBuilder, properties: TorrentClientProperties): RestTemplate { 20 | return builder 21 | .rootUri("${properties.url}/api/v2") 22 | .interceptors(listOf(QbitAuthInterceptor(properties))) 23 | .build() 24 | } 25 | 26 | @ConditionalOnProperty("clients.torrent.type", havingValue = "TRANSMISSION") 27 | @Transmission 28 | @Bean 29 | fun transmissionTemplate(builder: RestTemplateBuilder, properties: TorrentClientProperties): RestTemplate { 30 | val transmissionAuth = TransmissionAuthHandler(properties) 31 | return builder 32 | .rootUri("${properties.url}/transmission/rpc") 33 | .basicAuthentication(properties.username, properties.password) 34 | .interceptors(listOf(transmissionAuth)) 35 | .build() 36 | } 37 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/rest/TorrentClientProperties.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent.rest 2 | 3 | import com.github.schaka.rarrnomore.torrent.TorrentClientType 4 | import org.springframework.boot.context.properties.ConfigurationProperties 5 | 6 | @ConfigurationProperties(prefix = "clients.torrent") 7 | data class TorrentClientProperties( 8 | val type: TorrentClientType, 9 | val name: String, 10 | val url: String, 11 | val username: String, 12 | val password: String, 13 | val autoResume: Boolean = true 14 | ) 15 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/transmission/Transmission.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent.transmission 2 | 3 | import org.springframework.beans.factory.annotation.Qualifier 4 | 5 | @Target(AnnotationTarget.TYPE, AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.FUNCTION) 6 | @Retention(AnnotationRetention.RUNTIME) 7 | @Qualifier("transmission") 8 | annotation class Transmission() 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/transmission/TransmissionAuthHandler.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent.transmission 2 | 3 | import com.github.schaka.rarrnomore.torrent.rest.TorrentClientProperties 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.http.* 6 | import org.springframework.http.client.ClientHttpRequestExecution 7 | import org.springframework.http.client.ClientHttpRequestInterceptor 8 | import org.springframework.http.client.ClientHttpResponse 9 | 10 | class TransmissionAuthHandler( 11 | val properties: TorrentClientProperties, 12 | var lastSessionId: String = "" 13 | ) : ClientHttpRequestInterceptor { 14 | 15 | companion object { 16 | private val log = LoggerFactory.getLogger(this::class.java.enclosingClass) 17 | } 18 | 19 | override fun intercept( 20 | request: HttpRequest, 21 | body: ByteArray, 22 | execution: ClientHttpRequestExecution 23 | ): ClientHttpResponse { 24 | request.headers["X-Transmission-Session-Id"] = lastSessionId 25 | val response = execution.execute(request, body) 26 | 27 | if (response.statusCode == HttpStatus.CONFLICT) { 28 | lastSessionId = response.headers["X-Transmission-Session-Id"]?.get(0) 29 | ?: throw IllegalStateException("Can't find Transmission session id in response: $response") 30 | request.headers["X-Transmission-Session-Id"] = lastSessionId 31 | return execution.execute(request, body) 32 | } 33 | 34 | return response 35 | } 36 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/transmission/TransmissionRequest.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent.transmission 2 | 3 | data class TransmissionRequest( 4 | val method: String, 5 | val arguments: T 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/transmission/TransmissionResponse.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent.transmission 2 | 3 | class TransmissionResponse(val result: String, val arguments: T) 4 | 5 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/transmission/TransmissionService.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent.transmission 2 | 3 | import com.github.schaka.rarrnomore.hooks.TorrentInfo 4 | import com.github.schaka.rarrnomore.torrent.TorrentHashNotFoundException 5 | import com.github.schaka.rarrnomore.torrent.TorrentService 6 | import com.github.schaka.rarrnomore.torrent.qbit.QbitFileResponse 7 | import org.springframework.aot.hint.annotation.RegisterReflectionForBinding 8 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty 9 | import org.springframework.core.ParameterizedTypeReference 10 | import org.springframework.http.HttpEntity 11 | import org.springframework.http.HttpMethod 12 | import org.springframework.stereotype.Service 13 | import org.springframework.web.client.RestTemplate 14 | 15 | @ConditionalOnProperty("clients.torrent.type", havingValue = "TRANSMISSION") 16 | @RegisterReflectionForBinding(classes = [TransmissionRequest::class, TransmissionResponse::class, TransmissionTorrentResponse::class]) 17 | @Service 18 | class TransmissionService( 19 | @Transmission 20 | private var client: RestTemplate 21 | ) : TorrentService { 22 | 23 | override fun enrichTorrentInfo(info: TorrentInfo): TorrentInfo { 24 | val fileResponse = client.exchange( 25 | "/", 26 | HttpMethod.POST, 27 | HttpEntity(TransmissionRequest("torrent-get", object { 28 | val ids: List = listOf(info.hash) 29 | val fields: List = listOf("files") 30 | })), 31 | object : ParameterizedTypeReference>() {} 32 | ) 33 | 34 | val files = fileResponse.body?.arguments?.torrents 35 | if (files.isNullOrEmpty()) { 36 | throw TorrentHashNotFoundException("Torrent (${info.torrentName}) (${info.hash}) not in torrent client or files cannot be read") 37 | } 38 | 39 | info.addFiles(files.flatMap { it.files.map { it.name } }) 40 | return info 41 | } 42 | 43 | override fun resumeTorrent(hash: String) { 44 | client.postForEntity( 45 | "/", 46 | TransmissionRequest("torrent-start", object { 47 | val ids: List = listOf(hash) 48 | }), 49 | TransmissionResponse::class.java 50 | ) 51 | } 52 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/github/schaka/rarrnomore/torrent/transmission/TransmissionTorrentResponse.kt: -------------------------------------------------------------------------------- 1 | package com.github.schaka.rarrnomore.torrent.transmission 2 | 3 | data class TransmissionTorrentResponse(val torrents: List) 4 | data class TransmissionTorrentInfo(val files: List) 5 | data class TransmissionFileInfo(val name: String) -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8978 3 | 4 | clients: 5 | sonarr: 6 | url: "http://localhost:8989" 7 | api-key: "4ed7f4d0e8584d65ab9d47d944077ff3" 8 | radarr: 9 | url: "http://localhost:7878" 10 | api-key: "cd0912f129d344g9b69bb20d49fcbe49" 11 | torrent: 12 | type: QBITTORRENT 13 | name: qBittorrent 14 | auto-resume: true 15 | url: "http://localhost:8080" 16 | username: admin 17 | password: adminadmin 18 | --------------------------------------------------------------------------------