├── .eslintrc.js ├── client.js ├── index.js ├── config.example.js ├── LICENSE ├── package.json ├── download.js ├── util.js ├── README.md ├── .gitignore ├── radarr.js └── sonarr.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2020: true, 5 | node: true, 6 | }, 7 | extends: ["plugin:prettier/recommended", "eslint:recommended"], 8 | parserOptions: { 9 | ecmaVersion: 11, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /client.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | 3 | module.exports = function makeClient(settings, v3 = false) { 4 | return axios.create({ 5 | baseURL: `${settings.url}/api${v3 ? "/v3" : ""}`, 6 | headers: { 7 | "X-Api-Key": settings.apiKey, 8 | }, 9 | timeout: 120000, 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { argv } = require("yargs") 2 | .usage("Usage: node $0 [options]") 3 | .command("radarr", "Search for movies.") 4 | .command("sonarr", "Search for shows.") 5 | .demandCommand(1, 2) 6 | .number("recent") 7 | .describe( 8 | "recent", 9 | "Consider movies/shows downloaded in the last x days. If omitted, everything is searched for." 10 | ) 11 | .boolean("verbose") 12 | .describe("verbose", "Turn off log pretty printing.") 13 | .example( 14 | "node $0 radarr sonarr --recent 14", 15 | "Search radarr and sonarr for all movies/shows downloaded in the last 14 days." 16 | ) 17 | .help("help"); 18 | 19 | const radarrFlow = require("./radarr"); 20 | const sonarrFlow = require("./sonarr"); 21 | 22 | async function start() { 23 | if (argv._.indexOf("radarr") > -1) { 24 | await radarrFlow(); 25 | } 26 | 27 | if (argv._.indexOf("sonarr") > -1) { 28 | await sonarrFlow(); 29 | } 30 | } 31 | 32 | start(); 33 | -------------------------------------------------------------------------------- /config.example.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // amount of time to wait between search indexers requests in seconds 3 | timeout: 5, 4 | radarr: { 5 | // url you use to access radarr 6 | // https://your.seedbox.url/radarr 7 | // in case you are using http auth, the url should look like this 8 | // https://username:password@your.seedbox.url/radarr 9 | url: "", 10 | // radarr api key, found in settings -> general 11 | apiKey: "", 12 | // error threshold in % - torrent size difference which is ignored (treated as same release) 13 | // this is useful for eg. samples which some trackers include and others don't 14 | threshold: 5, 15 | // absolute path to the directory where we save matches 16 | torrentDir: "/tmp/torrents", 17 | // we search all "search-enabled" indexers - results from indexers listed below will be ignored 18 | // eg. ignoredIndexers: ["IPT", "TL"] 19 | ignoredIndexers: [], 20 | }, 21 | sonarr: { 22 | // same as for radarr 23 | url: "", 24 | apiKey: "", 25 | threshold: 5, 26 | torrentDir: "/tmp/torrents", 27 | ignoredIndexers: [], 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 boban-bmw 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cross-seedarr", 3 | "version": "1.0.0", 4 | "description": "Cross seeding helper for radarr & sonarr.", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/boban-bmw/cross-seedarr.git" 12 | }, 13 | "keywords": [ 14 | "radarr", 15 | "sonarr", 16 | "cross-seeding" 17 | ], 18 | "author": "Boban BMW", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/boban-bmw/cross-seedarr/issues" 22 | }, 23 | "homepage": "https://github.com/boban-bmw/cross-seedarr#readme", 24 | "devDependencies": { 25 | "eslint": "^7.2.0", 26 | "eslint-config-prettier": "^6.11.0", 27 | "eslint-plugin-prettier": "^3.1.3", 28 | "husky": "^4.2.5", 29 | "lint-staged": "^10.2.10", 30 | "prettier": "^2.0.5" 31 | }, 32 | "husky": { 33 | "hooks": { 34 | "pre-commit": "lint-staged" 35 | } 36 | }, 37 | "lint-staged": { 38 | "*.{js,md}": "prettier --write", 39 | "*.js": "eslint" 40 | }, 41 | "dependencies": { 42 | "axios": "^0.21.1", 43 | "moment": "^2.26.0", 44 | "pino": "^6.3.2", 45 | "pino-pretty": "^4.0.0", 46 | "yargs": "^15.3.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /download.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const axios = require("axios"); 3 | const fs = require("fs"); 4 | 5 | const { logger } = require("./util"); 6 | 7 | module.exports = function downloadRelease(basePath, release) { 8 | return new Promise((resolve, reject) => { 9 | const { downloadUrl } = release; 10 | 11 | const fileName = `[${release.indexer}]${release.title}.torrent`; 12 | 13 | const savePath = path.resolve(basePath, fileName); 14 | 15 | if (fs.existsSync(savePath)) { 16 | logger.info(`${fileName} already exists, skipping...`); 17 | 18 | resolve(); 19 | 20 | return; 21 | } 22 | 23 | const writeStream = fs.createWriteStream(savePath); 24 | 25 | writeStream.on("finish", () => { 26 | logger.info( 27 | `Successfully downloaded ${release.title} from ${release.indexer}` 28 | ); 29 | 30 | resolve(); 31 | }); 32 | 33 | writeStream.on("error", reject); 34 | 35 | axios 36 | .get(downloadUrl, { 37 | responseType: "stream", 38 | }) 39 | .then((response) => { 40 | response.data.pipe(writeStream); 41 | }) 42 | .catch((e) => { 43 | logger.error( 44 | e, 45 | `An error occurred while downloading ${release.title} from ${release.indexer}` 46 | ); 47 | 48 | resolve(); 49 | }); 50 | }); 51 | }; 52 | -------------------------------------------------------------------------------- /util.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const { argv } = require("yargs"); 4 | const logger = require("pino")({ 5 | prettyPrint: !argv.verbose, 6 | }); 7 | const moment = require("moment"); 8 | 9 | const { timeout } = require("./config"); 10 | 11 | const now = moment(); 12 | 13 | module.exports = { 14 | delay() { 15 | return new Promise((resolve) => { 16 | logger.info(`Waiting ${timeout} seconds...`); 17 | 18 | setTimeout(() => { 19 | resolve(); 20 | }, timeout * 1000); 21 | }); 22 | }, 23 | logger, 24 | mkdir(dir) { 25 | if (!fs.existsSync(dir)) { 26 | fs.mkdirSync(dir); 27 | } 28 | }, 29 | deleteEmptyFiles(dir) { 30 | const fileNames = fs.readdirSync(dir); 31 | 32 | for (const fileName of fileNames) { 33 | const filePath = path.join(dir, fileName); 34 | const file = fs.statSync(filePath); 35 | 36 | if (!file.isDirectory() && file.size === 0) { 37 | fs.unlinkSync(filePath); 38 | 39 | logger.info(`Deleted invalid file ${filePath}`); 40 | } 41 | } 42 | }, 43 | eligibleRelease(size, threshold) { 44 | return (release) => { 45 | const sizeDifference = Math.abs(size - release.size); 46 | const sizeDifferencePercentage = sizeDifference / size; 47 | 48 | return sizeDifferencePercentage < threshold / 100; 49 | }; 50 | }, 51 | validIndexers(ignoredIndexers) { 52 | return (release) => ignoredIndexers.indexOf(release.indexer) === -1; 53 | }, 54 | recentlyDownloaded(item) { 55 | if (!argv.recent) { 56 | return true; 57 | } 58 | 59 | return now.diff(moment(item.dateAdded), "day") <= argv.recent; 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cross-seedarr 2 | 3 | Cross seeding helper for radarr & sonarr. 4 | 5 | | Which problem does this solve? 6 | 7 | You don't need to manually cross-seed a release among your many trackers. 8 | 9 | ## How it works 10 | 11 | It searches all your search-enabled indexers for a movie/show and matches releases within `config.threshold` percent in size. 12 | This is done because certain trackers rename releases, include/exclude samples/nfos, etc. 13 | 14 | Only monitored and downloaded items are considered. For TV shows, only season packs and complete packs are searched for. 15 | 16 | ## Installation 17 | 18 | Prerequisites: `git` and `node` (I recommend using [nvm](https://github.com/nvm-sh/nvm)) 19 | 20 | ``` 21 | git clone https://github.com/boban-bmw/cross-seedarr.git 22 | cd cross-seedarr && npm install --production 23 | cp config.example.js config.js 24 | vim config.js # edit config.js to suit your needs 25 | ``` 26 | 27 | ## Usage 28 | 29 | ``` 30 | Usage: node index.js [options] 31 | 32 | Commands: 33 | index.js radarr Search for movies. 34 | index.js sonarr Search for shows. 35 | 36 | Options: 37 | --version Show version number [boolean] 38 | --recent Consider movies/shows downloaded in the last x days. If omitted, 39 | everything is searched for. [number] 40 | --verbose Turn off log pretty printing. [boolean] 41 | --help Show help [boolean] 42 | 43 | Examples: 44 | node index.js radarr sonarr --recent 14 Search radarr and sonarr for all 45 | movies/shows downloaded in the last 46 | 14 days. 47 | ``` 48 | 49 | `config.torrentDir` will contain all potential matching torrents - pass these to [autotorrent](https://github.com/JohnDoee/autotorrent). 50 | 51 | `cross-seedarr` will not download duplicate `.torrent` files - so it is recommended to use `autotorrent` without the `-d` flag 52 | 53 | ### Tested versions 54 | 55 | - `radarr v3` 56 | - `sonarr v3` 57 | - `node v14` 58 | - `ubuntu v20` - other OS-es should work as well 59 | 60 | ## License 61 | 62 | MIT 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Our stuff 2 | 3 | config.js 4 | 5 | # Generated stuff 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | jspm_packages/ 49 | 50 | # TypeScript v1 declaration files 51 | typings/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variables file 78 | .env 79 | .env.test 80 | 81 | # parcel-bundler cache (https://parceljs.org/) 82 | .cache 83 | 84 | # Next.js build output 85 | .next 86 | 87 | # Nuxt.js build / generate output 88 | .nuxt 89 | dist 90 | 91 | # Gatsby files 92 | .cache/ 93 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 94 | # https://nextjs.org/blog/next-9-1#public-directory-support 95 | # public 96 | 97 | # vuepress build output 98 | .vuepress/dist 99 | 100 | # Serverless directories 101 | .serverless/ 102 | 103 | # FuseBox cache 104 | .fusebox/ 105 | 106 | # DynamoDB Local files 107 | .dynamodb/ 108 | 109 | # TernJS port file 110 | .tern-port 111 | -------------------------------------------------------------------------------- /radarr.js: -------------------------------------------------------------------------------- 1 | const { radarr } = require("./config"); 2 | const { 3 | delay, 4 | logger, 5 | mkdir, 6 | deleteEmptyFiles, 7 | eligibleRelease, 8 | validIndexers, 9 | recentlyDownloaded, 10 | } = require("./util"); 11 | const makeClient = require("./client"); 12 | const downloadRelease = require("./download"); 13 | 14 | async function getMovieReleases(radarrApi, movie) { 15 | const movieName = `${movie.title} (${movie.year})`; 16 | let releases = []; 17 | 18 | try { 19 | const { movieFile } = movie; 20 | 21 | logger.info(`Searching for ${movieName}...`); 22 | 23 | const searchResults = ( 24 | await radarrApi.get(`/release?movieId=${movie.id}`) 25 | ).data.filter((result) => result.protocol === "torrent"); 26 | 27 | releases = searchResults 28 | .filter(eligibleRelease(movieFile.size, radarr.threshold)) 29 | .filter(validIndexers(radarr.ignoredIndexers)); 30 | 31 | logger.info( 32 | `Found ${searchResults.length} result(s) - Eligible: ${releases.length}` 33 | ); 34 | } catch (e) { 35 | logger.error( 36 | e, 37 | `An error occurred while searching for ${movieName}, skipping...` 38 | ); 39 | } 40 | 41 | return releases; 42 | } 43 | 44 | module.exports = async function radarrFlow() { 45 | if (!radarr || !radarr.url || !radarr.apiKey) { 46 | logger.warn("radarr config unset, skipping..."); 47 | return; 48 | } 49 | 50 | mkdir(radarr.torrentDir); 51 | 52 | const radarrApi = makeClient(radarr); 53 | 54 | try { 55 | logger.info("Fetching movies..."); 56 | 57 | const { data: allMovies } = await radarrApi.get("/movie"); 58 | 59 | const movies = allMovies 60 | .filter((movie) => movie.monitored && movie.downloaded && movie.hasFile) 61 | .filter((movie) => recentlyDownloaded(movie.movieFile)); 62 | 63 | logger.info( 64 | `Fetching movies complete - Eligible movies found: ${movies.length}` 65 | ); 66 | 67 | let counter = 0; 68 | 69 | for (const movie of movies) { 70 | const releases = await getMovieReleases(radarrApi, movie); 71 | 72 | for (const release of releases) { 73 | try { 74 | await downloadRelease(radarr.torrentDir, release); 75 | } catch (e) { 76 | logger.error( 77 | e, 78 | `An error occurred while saving ${release.title} from ${release.indexer}` 79 | ); 80 | } 81 | } 82 | 83 | counter += 1; 84 | 85 | logger.info( 86 | `Progress: ${((counter / movies.length) * 100).toFixed( 87 | 2 88 | )}% (${counter}/${movies.length})` 89 | ); 90 | 91 | await delay(); 92 | } 93 | 94 | deleteEmptyFiles(radarr.torrentDir); 95 | } catch (e) { 96 | logger.error(e, "An error occurred while processing movies"); 97 | } 98 | }; 99 | -------------------------------------------------------------------------------- /sonarr.js: -------------------------------------------------------------------------------- 1 | const { sonarr } = require("./config"); 2 | const { 3 | delay, 4 | logger, 5 | mkdir, 6 | deleteEmptyFiles, 7 | eligibleRelease, 8 | validIndexers, 9 | recentlyDownloaded, 10 | } = require("./util"); 11 | const makeClient = require("./client"); 12 | const downloadRelease = require("./download"); 13 | 14 | async function getSeasonReleases(sonarrApi, show, season) { 15 | const seasonName = `${show.title} (${show.year}) S${ 16 | season.seasonNumber < 10 ? 0 : "" 17 | }${season.seasonNumber}`; 18 | 19 | let releases = []; 20 | 21 | try { 22 | logger.info(`Searching for ${seasonName}...`); 23 | 24 | const searchResults = ( 25 | await sonarrApi.get( 26 | `/release?seriesId=${show.id}&seasonNumber=${season.seasonNumber}` 27 | ) 28 | ).data.filter((result) => result.protocol === "torrent"); 29 | 30 | releases = searchResults 31 | .filter(eligibleRelease(season.statistics.sizeOnDisk, sonarr.threshold)) 32 | .filter(validIndexers(sonarr.ignoredIndexers)); 33 | 34 | if (show.seasons.length > 1 && season.seasonNumber === 1) { 35 | const completeShowSize = show.seasons.reduce( 36 | (prev, next) => prev + next.statistics.sizeOnDisk, 37 | 0 38 | ); 39 | 40 | const completePacks = searchResults 41 | .filter(eligibleRelease(completeShowSize, sonarr.threshold)) 42 | .filter(validIndexers(sonarr.ignoredIndexers)); 43 | 44 | releases.push(...completePacks); 45 | } 46 | 47 | logger.info( 48 | `Found ${searchResults.length} results(s) - Eligible: ${releases.length}` 49 | ); 50 | } catch (e) { 51 | logger.error( 52 | e, 53 | `An error occurred while searching for ${seasonName}, skipping...` 54 | ); 55 | } 56 | 57 | return releases; 58 | } 59 | 60 | module.exports = async function sonarrFlow() { 61 | if (!sonarr || !sonarr.url || !sonarr.apiKey) { 62 | logger.warn("sonarr config unset, skipping..."); 63 | return; 64 | } 65 | 66 | mkdir(sonarr.torrentDir); 67 | 68 | const sonarrApi = makeClient(sonarr, true); 69 | 70 | try { 71 | logger.info("Fetching series..."); 72 | 73 | const { data: allSeries } = await sonarrApi.get("/series"); 74 | 75 | const series = allSeries.filter( 76 | (series) => series.statistics.episodeFileCount > 0 77 | ); 78 | 79 | logger.info("Fetching series complete - processing episodes..."); 80 | 81 | let counter = 0; 82 | 83 | for (const show of series) { 84 | const { data: allEpisodeFiles } = await sonarrApi.get( 85 | `/episodeFile?seriesId=${show.id}` 86 | ); 87 | 88 | for (const season of show.seasons) { 89 | const relevantEpisodes = allEpisodeFiles 90 | .filter((episode) => episode.seasonNumber === season.seasonNumber) 91 | .filter(recentlyDownloaded); 92 | 93 | if ( 94 | season.monitored && 95 | season.statistics.percentOfEpisodes === 100 && 96 | relevantEpisodes.length > 0 97 | ) { 98 | const releases = await getSeasonReleases(sonarrApi, show, season); 99 | 100 | for (const release of releases) { 101 | try { 102 | await downloadRelease(sonarr.torrentDir, release); 103 | } catch (e) { 104 | logger.error( 105 | e, 106 | `An error occurred while saving ${release.title} from ${release.indexer}` 107 | ); 108 | } 109 | } 110 | 111 | await delay(); 112 | } 113 | } 114 | 115 | counter += 1; 116 | 117 | logger.info( 118 | `Progress: ${((counter / series.length) * 100).toFixed( 119 | 2 120 | )}% (${counter}/${series.length})` 121 | ); 122 | } 123 | 124 | deleteEmptyFiles(sonarr.torrentDir); 125 | } catch (e) { 126 | logger.error(e, "An error occurred while processing series"); 127 | } 128 | }; 129 | --------------------------------------------------------------------------------