├── .github └── workflows │ └── docker-publish.yml ├── .gitignore ├── Dockerfile ├── README.MD ├── app.py ├── arr ├── base.py ├── filter.py ├── radarr.py └── sonarr.py ├── config └── config-sample.json ├── console.py ├── requirements.txt └── setup ├── args.py ├── defaults.py └── logger.py /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | workflow_dispatch: 10 | schedule: 11 | - cron: '27 21 * * *' 12 | 13 | # push: 14 | # branches: '*' 15 | # # Publish semver tags as releases. 16 | # tags: [ 'v*.*.*' ] 17 | # pull_request: 18 | # branches: [ "main" ] 19 | 20 | env: 21 | # Use docker.io for Docker Hub if empty 22 | REGISTRY: ghcr.io 23 | # github.repository as / 24 | IMAGE_NAME: ${{ github.repository }} 25 | 26 | 27 | jobs: 28 | build: 29 | 30 | runs-on: ubuntu-latest 31 | permissions: 32 | contents: read 33 | packages: write 34 | # This is used to complete the identity challenge 35 | # with sigstore/fulcio when running outside of PRs. 36 | id-token: write 37 | 38 | steps: 39 | - name: Checkout repository 40 | uses: actions/checkout@v3 41 | 42 | # Install the cosign tool except on PR 43 | # https://github.com/sigstore/cosign-installer 44 | - name: Install cosign 45 | if: github.event_name != 'pull_request' 46 | uses: sigstore/cosign-installer@main #v2.6.0 47 | with: 48 | cosign-release: 'v1.13.1' 49 | 50 | 51 | # Workaround: https://github.com/docker/build-push-action/issues/461 52 | - name: Setup Docker buildx 53 | uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf 54 | 55 | # Login against a Docker registry except on PR 56 | # https://github.com/docker/login-action 57 | - name: Log into registry ${{ env.REGISTRY }} 58 | if: github.event_name != 'pull_request' 59 | uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c 60 | with: 61 | registry: ${{ env.REGISTRY }} 62 | username: ${{ github.actor }} 63 | password: ${{ secrets.GITHUB_TOKEN}} 64 | 65 | # Extract metadata (tags, labels) for Docker 66 | # https://github.com/docker/metadata-action 67 | - name: Extract Docker metadata 68 | id: meta 69 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 70 | with: 71 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 72 | 73 | # Build and push Docker image with Buildx (don't push on PR) 74 | # https://github.com/docker/build-push-action 75 | - name: Build and push Docker image 76 | id: build-and-push 77 | uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a 78 | with: 79 | context: . 80 | push: ${{ github.event_name != 'pull_request' }} 81 | tags: ${{ steps.meta.outputs.tags }} 82 | labels: ${{ steps.meta.outputs.labels }} 83 | cache-from: type=gha 84 | cache-to: type=gha,mode=max 85 | 86 | 87 | # Sign the resulting Docker image digest except on PRs. 88 | # This will only write to the public Rekor transparency log when the Docker 89 | # repository is public to avoid leaking data. If you would like to publish 90 | # transparency data even for private images, pass --force to cosign below. 91 | # https://github.com/sigstore/cosign 92 | - name: Sign the published Docker image 93 | if: ${{ github.event_name != 'pull_request' }} 94 | env: 95 | COSIGN_EXPERIMENTAL: "true" 96 | # This step uses the identity token to provision an ephemeral certificate 97 | # against the sigstore community Fulcio instance. 98 | run: echo "${{ steps.meta.outputs.tags }}" | xargs -I {} cosign sign {}@${{ steps.build-and-push.outputs.digest }} 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .venv 3 | config.json 4 | *pycache* 5 | request.cache 6 | *.log 7 | *.text 8 | *.txt 9 | config.json 10 | *.lock 11 | /logs -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-alpine 2 | RUN apk add --no-cache tini 3 | 4 | RUN mkdir /app 5 | COPY /arr /app/arr/ 6 | COPY /setup /app/setup/ 7 | COPY *.py /app/ 8 | COPY requirements.txt /app/requirements.txt 9 | RUN pip3.11 install "cython<3.0.0" && pip install --no-build-isolation pyyaml==6.0 10 | RUN pip3.11 install -r /app/requirements.txt 11 | 12 | RUN mkdir /config 13 | RUN mkdir /logs 14 | 15 | 16 | WORKDIR /app 17 | VOLUME /config 18 | ENV CROSSARR_DOCKER TRUE 19 | ENTRYPOINT ["tini", "--","/app/app.py"] 20 | 21 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | I have a very specific need, and I decided to share this with other who may find themselves in a similar situation 4 | 1. I have a large library, and with cross-seedable torrents. However, much of this library does not have proper torrents from private trackers currently 5 | 2. I also have multiple versions of some TV shows or movies. From multiple arr clients, a single arr client may grab multiple versions of a TV show/Movie 6 | 7 | This script will scan your history, and utilize the data from that to match to current release via prowlarr. 8 | The benefit to this is that it allows for all releases to be cross-seed. Even if the file has been replaced, and upgraded in sonarr/radarr 9 | 10 | This is fairly easy to do with radarr as at most you only have to match one or two entries to have some confirmation that a user downloaded, and then was able to import a release into their library. 11 | 12 | Sonarr is a bit more of a challenge, as each episode is a different release. However, it is very helpful if you get a full season pack. As their is a lot less work to do in confirm you have the complete season. In the case of individual episodes crossarr will combine entries based on attribute. IT will then sum of the size of the release, along with compare the count of episodes to verify if a full season has been downloaded 13 | 14 | I would recommend having Sonarr download full season only 15 | You could add this regex to must not contain to block 99 percent of all single episodes 16 | -> E[0-9][0-9]+ 17 | 18 | # How to Install/Get Started 19 | Instructions are provided for Linux and Windows 20 | Older verison of python may work. but have not been tested 21 | ## Linux: 22 | ### Required 23 | * python 3.9 24 | ### Install 25 | * python3.9 -m pip install --user virtualenv 26 | * python3.9 -m venv env 27 | * source env/bin/activate 28 | * which python -> should be the virtualenv 29 | * pip3 install -r requirements.txt 30 | * deactivate -> Do this after installing the requirements 31 | 32 | ## Windows 33 | ### Required 34 | * python3.9 35 | ### Install 36 | * py -3.9 -m pip install --user virtualenv 37 | * py -3.9 -m venv env 38 | * .\env\Scripts\activate 39 | * which python -> should be the virtualenv 40 | * py -m pip install -r requirements.txt 41 | * deactivate -> Do this after installing the requirements 42 | 43 | ## General virtualenv Guide 44 | 45 | 46 | General Guide: https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/ 47 | 48 | 49 | # Usage 50 | ## Activating Virtual Env 51 | ### Windows 52 | * source env/bin/activate 53 | ### Linux 54 | * .\env\Scripts\activate 55 | 56 | 57 | crossarr [options] sonarr [-h] [-a API] [-u URL] [-f FOLDER] 58 | 59 | or 60 | 61 | crossarr [options] radarr [-h] [-a API] [-u URL] [-f FOLDER] 62 | 63 | 64 | 65 | ``` 66 | options 67 | -h, --help Show this help message and exit. 68 | -c CONFIG, --config CONFIG 69 | Path to a configuration file. 70 | --print_config [={comments,skip_null,skip_default}+] 71 | Print the configuration after applying all other arguments and exit. 72 | -n CLIENTNAME, --clientname CLIENTNAME 73 | Name of client use for logfile/lockfile sonarr or 74 | raddarr use if not set (default: null) 75 | -v {Debug,DEBUG,debug,INFO,info,Info,off,OFF,Off}, --loglevel {Debug,DEBUG,debug,INFO,info,Info,off,OFF,Off} 76 | what level to set log to flexible case-senstivity main options are [DEBUG,INFO,OFF] 77 | (default: info) 78 | -r ROWS, --rows ROWS Advanced Feature to set how many table rows to render 79 | for Messages (default: 5) 80 | 81 | -t THRESHOLD, --threshold THRESHOLD 82 | The max size difference \% a match can have (type: int, default: 1) 83 | -d DAYS, --days DAYS Max Age of a release in days (type: int, default: 99999999999999999999) 84 | -a PROWLARRAPI, --prowlarrapi PROWLARRAPI 85 | Prowlar API key (default: null) 86 | -p PROWLARRURL, --prowlarrurl PROWLARRURL 87 | Prowlar URL (default: null) 88 | -f {grabbed,imported}, --flag {grabbed,imported} 89 | grabbed= Releases grabbed and added to any client imported= Releases completed, and imported into library (default: imported) 90 | -i INDEXERS [INDEXERS ...], --indexers INDEXERS [INDEXERS ...] 91 | Names of Indexer Uses Regex to Match Names (default: null) 92 | -it [0-44640], --interval [0-44640] 93 | Run the script every x minutes, 0 turns off (type: 94 | interval, default: 0) 95 | 96 | 97 | ``` 98 | 99 | 100 | ## Example Usage 101 | ``` 102 | crossarr -c config radarr -u url -api a key 103 | ``` 104 | Take note of the order of the option it is important because of the subcommand 105 | Basically the only thing that should come after radarr or sonarr is the url option , and api option. 106 | Everything else needs to be before 107 | 108 | 109 | ## Interval 110 | 111 | Only 1 instance can run per lock file name, change --clientname if you want to run instance at same time 112 | 113 | If instance is running and next run interval time passes, that interval will be skipped. 114 | Program will have to wait for next interval when it finishes 115 | 116 | Max Value:44640 Minutes=31 days 117 | 118 | ### Example 119 | 120 | #### Times 121 | * Interval:60 Minutes 122 | * Runtime: 1HR 30 Minutes 123 | 124 | First run will be at 0 125 | Since second interval is at 60 minutes and program is still running that will be skipped 126 | Third interval is at 2 hours. At this time program should be done running, so this interval will be ran 127 | 128 | ## LogFiles 129 | 130 | Logfiles are saved in the logs directory. Within the same folder the program is stored 131 | Alternately when using docker /logs folder is used to set were to mount the folder on host 132 | 133 | # Docker 134 | 135 | ## ENV 136 | 137 | | VAR | DESC | 138 | | ------------- | ------------- | 139 | | TZ|Timezone example:America/Belem | 140 | | CROSSARR_CLIENT|value for --clientname arg| 141 | | CROSSARR_INTERVAL | Value for --interval arg | 142 | 143 | 144 | ## DOCKER PATHS 145 | 146 | | DIR| DESC | 147 | | ------------- | ------------- | 148 | | /logs|Directory for logfiles | 149 | | /output|Directory for storing torrent files | 150 | | /config|Directory with config.json| 151 | 152 | ## PASSING ARGS 153 | use the command property of docker run or compose to pass args 154 | 155 | ## Docker Run 156 | ``` 157 | sudo docker run -it --rm -v /home/user/config.json:/config/ -v /home/user/logs/:/logs --name crossarr ghcr.io/tmd20/crossarr:main sonarr 158 | ``` 159 | ## Docker Compose 160 | As a general note you need to make an empty file/user an existing one if you want to pass docker a file, otherwise it will make a directory 161 | ``` 162 | crossarr: 163 | image: ghcr.io/tmd20/crossarr:main 164 | container_name: crossarr 165 | environment: 166 | - TZ=enter for valid logs 167 | volumes: 168 | - /home/torrents/crossarr/config.json:/config 169 | - /home/torrents/crossarr/logs/:/logs/ 170 | - /home/Downloads/Torrents:/output 171 | command: radarr 172 | restart: always 173 | networks: 174 | - mynetwork 175 | ``` 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | # Future Updates 185 | Support imported episodes 186 | 187 | * Get size information from local files; I move my files to another drive, which sonarr is not aware of. 188 | However other user may have the file at the original location from sonarr. 189 | * support scheduling with docker right now it only runs once it should be a daemon that runs at a interval 190 | 191 | 192 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import arr.radarr as radarrAPI 3 | import arr.sonarr as sonarrAPI 4 | import setup.args as args 5 | import console 6 | import setup.logger as logger 7 | import pathlib 8 | import portalocker 9 | import schedule 10 | import time 11 | import threading 12 | import os 13 | 14 | import setup.defaults as defaults 15 | 16 | 17 | def run_threaded(job_func,userargs): 18 | job_thread = threading.Thread(target=job_func,args=[userargs]) 19 | job_thread.start() 20 | 21 | 22 | def run(userargs,block=False): 23 | try: 24 | with portalocker.Lock(os.path.join(defaults.getHomeDir(),f"{userargs.clientname}.lock"),fail_when_locked=block, timeout=1000) as fh: 25 | console.logging.info(f"Start RUN") 26 | console.mainConsole.print(console.Panel(f"Looking through {userargs.subcommand} for matches",style=console.normal_header_style)) 27 | #Create Folder for log and torrents 28 | if userargs.subcommand=="sonarr": 29 | pathlib.Path(userargs.sonarr.folder).mkdir(parents=True, exist_ok=True) 30 | sonarrObj=sonarrAPI.Sonarr() 31 | sonarrObj.dryrun=userargs["dryrun"] 32 | sonarrObj.process() 33 | else: 34 | pathlib.Path(userargs.radarr.folder).mkdir(parents=True, exist_ok=True) 35 | radarrObj=radarrAPI.Radarr() 36 | radarrObj.dryrun=userargs["dryrun"] 37 | radarrObj.process() 38 | console.logging.info(f"Finish RUN") 39 | 40 | except Exception as E: 41 | if isinstance(E,portalocker.exceptions.AlreadyLocked): 42 | #Fix Later to use rich if possible 43 | print(E) 44 | console.logging.info(str(E)) 45 | #probably a crash 46 | else: 47 | print(E) 48 | console.logging.info(E) 49 | lock=os.path.join(defaults.getHomeDir(),f"{userargs.clientname}.lock") 50 | if os.path.exists(lock): 51 | os.remove(lock) 52 | console.logging.info(f"Finish RUN") 53 | 54 | 55 | def main(): 56 | logger.setupLogging() 57 | 58 | userargs=args.getArgs() 59 | if userargs.interval>0: 60 | console.logging.info(f"Running Every {userargs.interval} Minutes") 61 | schedule.every(userargs.interval).minutes.do(run_threaded,run,userargs=userargs) 62 | schedule.run_all() 63 | while True: 64 | schedule.run_pending() 65 | time.sleep(1) 66 | else: 67 | console.logging.info(f"Running Program Once") 68 | run(userargs,block=True) 69 | 70 | 71 | 72 | if __name__ == '__main__': 73 | main() 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /arr/base.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import re 4 | 5 | import arrow 6 | import requests_cache 7 | import urllib3 8 | session = requests_cache.CachedSession( 9 | 'request.cache',expire_after=datetime.timedelta(days=1)) 10 | 11 | 12 | import setup.args as args 13 | import console 14 | 15 | class Base(): 16 | def __init__(self): 17 | self.args=args.getArgs() 18 | self.currDate = arrow.get() 19 | self.threshold = self.args.threshold 20 | self.days = self.args.days 21 | self.userindexers = self.args.indexers 22 | self.flag = self.args.flag 23 | self.session=session 24 | self.prowlarrurl=self.args.prowlarrurl 25 | self.prowlarrapi=self.args.prowlarrapi 26 | self.dryrun=None 27 | 28 | 29 | 30 | 31 | 32 | ################################################################### 33 | #Process Functions 34 | ##################################################################### 35 | 36 | def process(self,dryrun=False): 37 | self._getIndexerIDs() 38 | self._setConsole() 39 | self._setArrDataNormalizers() 40 | self._findCrossSeeds() 41 | self._finalOutPut() 42 | def _findCrossSeeds(self): 43 | None 44 | def _finalOutPut(self): 45 | console.Console().print(f"\n\nFinal Statistics",style="deep_pink4") 46 | msg= \ 47 | f""" 48 | Succesful Downloads: {self.download} 49 | Failed Downloads: {self.downloadFails} 50 | Successful Request: {self.successReq} 51 | Failed Request: {self.errorReq} 52 | """ 53 | console.Console().print(msg,style="bold") 54 | console.logging.info(msg) 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | ################################################################### 63 | #Process Helpers 64 | ##################################################################### 65 | 66 | 67 | 68 | def _downloadItem(self,release): 69 | self._rowHelper() 70 | basename = f"[{release['indexer']}] {release['title']}.torrent" 71 | basename=self.titleFixer(basename) 72 | finalURL=release["downloadUrl"] 73 | data = session.get(finalURL) 74 | if self.dryrun: 75 | msg=f"Dry Run: [{release['indexer']}] {release['title']}.torrent" 76 | self.messageTable.add_row("Download",msg,style="green") 77 | console.logging.info(msg) 78 | elif data.status_code!=200: 79 | msg=f"Request to {finalURL} was not succesful with status code {data.status_code}" 80 | self.messageTable.add_row("Error",msg,style="red") 81 | console.logging.error(msg) 82 | self.downloadFails=self.downloadFails+1 83 | else: 84 | try: 85 | savePath=os.path.join(self.folder,basename) 86 | with open(savePath,"wb") as file: 87 | file.write(data.content) 88 | msg=f"Saved Download to -> {savePath}" 89 | self.messageTable.add_row("Download",msg,style="green") 90 | console.logging.info(msg) 91 | self.download=self.download+1 92 | except: 93 | msg=f"Could not Saved Download to -> {savePath}" 94 | self.messageTable.add_row("Error",msg,style="RED") 95 | console.logging.error(msg) 96 | self.downloadFails=self.downloadFails+1 97 | 98 | 99 | 100 | 101 | 102 | 103 | def _titleSimplify(self,title): 104 | title=os.path.basename(title) 105 | title=os.path.splitext(title)[0] 106 | return title 107 | 108 | def titleFixer(self,title): 109 | title=re.sub("/","_",title) 110 | return title 111 | 112 | 113 | 114 | 115 | 116 | def _getIndexerIDs(self): 117 | indexerURL=f"{self.prowlarrurl}/api/v1/indexer" 118 | 119 | 120 | indexerIDs = [] 121 | indexerNames = [] 122 | req=self.session.get(indexerURL,params={"apikey":self.prowlarrapi}) 123 | allIndexer=req.json() 124 | 125 | for ele in self.userindexers: 126 | for indexer in allIndexer: 127 | if indexer["name"].lower()==ele.lower(): 128 | indexerIDs.insert(0,indexer["id"]) 129 | indexerNames.insert(0,indexer["name"]) 130 | break 131 | elif re.search(ele, indexer["name"], re.IGNORECASE): 132 | indexerIDs.append(indexer["id"]) 133 | indexerNames.append(indexer["name"]) 134 | self.indexerIDs= list(set(indexerIDs)) 135 | self.indexerNames = list(set(indexerNames)) 136 | console.logging.info(f"allIndexers: {allIndexer}") 137 | console.logging.info(f"indexersIDs: {indexerIDs}") 138 | console.logging.info(f"indexersNames: {indexerNames}") 139 | print(indexerIDs,indexerNames) 140 | 141 | if len(indexerNames)==0: 142 | print("No indexers") 143 | quit() 144 | 145 | 146 | 147 | 148 | def _setArrDataNormalizers(self): 149 | self.arrTitle=lambda x:x["sourceTitle"] 150 | self.arrID=lambda x:x["seriesId"] 151 | self.arrDate=lambda x:x["date"] 152 | self.arrEvent=lambda x:x["eventType"] 153 | self.arrQuality=lambda x:x["quality"]["quality"]["name"] 154 | self.arrGroup=lambda x:x["data"]["releaseGroup"] 155 | self.arrSize=lambda x:x["data"]["size"] 156 | self.arrLang=lambda x:x["language"][0]["name"] 157 | 158 | 159 | 160 | def _normalizeObj(self,obj): 161 | out={} 162 | out["title"]=self.arrTitle(obj) 163 | out["id"]=self.arrID(obj) 164 | out["lang"]=self.arrLang(obj) 165 | out["date"]=self.arrDate(obj) 166 | out["eventType"]=self.arrEvent(obj) 167 | out["quality"]=self.arrQuality(obj) 168 | out["releaseGroup"]=self.arrGroup(obj) 169 | out["size"]=self.arrSize(obj) 170 | return out 171 | 172 | 173 | ################################################################### 174 | #Message Functions 175 | ##################################################################### 176 | 177 | def _setConsole(self): 178 | self._setMessagesTable() 179 | self._setProgressBars() 180 | self.renderGroup=console.Group(self.messageTable,self.overallProgress) 181 | self.outputConsole=console.Console() 182 | self.live=console.Live(self.renderGroup,console=self.outputConsole) 183 | # counters for final message output 184 | self.successReq=0 185 | self.errorReq=0 186 | self.download=0 187 | self.downloadFails=0 188 | 189 | def _setMessagesTable(self): 190 | #Message table 191 | self.messageTable=console.Table(title="Program Messages") 192 | self.messageTable.add_column("Name") 193 | self.messageTable.add_column("Message") 194 | self.maxTableRows=self.args.rows 195 | self.messageTable=console.Table() 196 | 197 | 198 | def _setProgressBars(self): 199 | self.overallProgress= console.Progress(console.TaskProgressColumn(),console.BarColumn(),console.TextColumn("{task.description}")) 200 | 201 | def _rowHelper(self): 202 | if len(self.messageTable.rows)==self.maxTableRows: 203 | self.messageTable=console.Table(title="Program Messages") 204 | self.messageTable.add_column("Name") 205 | self.messageTable.add_column("Message") 206 | self.renderGroup=console.Group(self.messageTable,self.overallProgress) 207 | self.live.update(self.renderGroup) 208 | -------------------------------------------------------------------------------- /arr/filter.py: -------------------------------------------------------------------------------- 1 | import re 2 | import jellyfish 3 | from guessit import guessit 4 | import arrow 5 | import console 6 | import functools 7 | import setup.args as args 8 | 9 | 10 | arg=args.getArgs() 11 | def filterMovieReleases(ele,prevDownload): 12 | title=prevDownload["title"] 13 | size=int(prevDownload["size"]) 14 | prevDownloadInfo=guessit(title) 15 | releaseGroup = prevDownloadInfo.get("release_group") 16 | resolution = prevDownloadInfo.get("screen_size") 17 | quality=prevDownloadInfo.get("source") 18 | resolution = prevDownloadInfo.get("screen_size") 19 | videoCodec = prevDownloadInfo.get("video_codec") 20 | videoProfile = prevDownloadInfo.get("video_profile") 21 | filterFunctions=[functools.partial(matchByTitle,releaseGroup,title),functools.partial(matchBySize,size),functools.partial(matchQuality,quality), 22 | functools.partial(matchRes,resolution),functools.partial(matchVideoCodec,videoCodec),functools.partial(matchVideoProfile,videoProfile), 23 | functools.partial(matchSpecial,title) 24 | ] 25 | infoDict={'title':ele['title'],'size':ele['size'],'indexer':ele['indexer'],'infourl':ele['guid']} 26 | console.logging.info(f"Trying to match {infoDict}") 27 | for funct in filterFunctions: 28 | if not funct(ele): 29 | return False 30 | console.logging.info(f"{ele['title']} matches completely") 31 | return True 32 | 33 | def filterTVReleases(prevDownload,ele,epCount): 34 | title=prevDownload["title"] 35 | releaseData=guessit(title,"") 36 | releaseGroup = releaseData.get("release_group") 37 | resolution = releaseData.get("screen_size") 38 | quality=releaseData.get("source") 39 | resolution = releaseData.get("screen_size") 40 | videoCodec = releaseData.get("video_codec") 41 | videoProfile = releaseData.get("video_profile") 42 | filterFunctions=[functools.partial(matchByTitle,releaseGroup,title),functools.partial(matchQuality,quality), 43 | functools.partial(matchRes,resolution),functools.partial(matchVideoCodec,videoCodec),functools.partial(matchVideoProfile,videoProfile), 44 | functools.partial(matchSpecial,title) 45 | ] 46 | infoDict={'title':ele['title'],'size':ele['size'],'indexer':ele['indexer'],'infourl':ele['guid']} 47 | console.logging.info(f"Trying to match {infoDict}") 48 | for funct in filterFunctions: 49 | if not funct(ele): 50 | return False 51 | 52 | 53 | 54 | def matchDate(currDate,ele): 55 | if(matchDateHelper(currDate,ele)): 56 | return True 57 | return False 58 | def matchDateHelper(currDate,input): 59 | maxAge=arg.days 60 | dateString=input.get("date") or input.get("publishDate") 61 | itemDate=arrow.get(dateString) 62 | return (currDate-itemDate).days<=maxAge 63 | 64 | def matchBySize(matchSize,ele): 65 | threshold=arg.threshold/100 66 | if abs(int(matchSize)-int(ele["size"]))/int(matchSize) <=threshold: 67 | console.logging.info(f"{ele['title']} size matches") 68 | return True 69 | return False 70 | 71 | 72 | def matchBySizeSeason(seasonHistory,matchSize): 73 | threshold=arg.threshold/100 74 | season=list(filter(lambda x: re.search("E[0-9][0-9]+",x["title"],re.IGNORECASE)==None, seasonHistory)) 75 | return list(filter(lambda x:(abs(matchSize-int(x["size"]))/matchSize)<=threshold,season)) 76 | 77 | 78 | def matchBySizeEpisodes(seasonHistory,matchSize): 79 | threshold=arg.threshold/100/100 80 | episodes=list(filter(lambda x: re.search("E[0-9][0-9]+",x["title"],re.IGNORECASE), seasonHistory)) 81 | episodeSize=list(map(lambda x:int(x["size"]),episodes)) 82 | if (abs(matchSize-sum(episodeSize))/matchSize)<=threshold: 83 | return episodes 84 | return [] 85 | 86 | def matchByTitle(matchGroup,matchTitle,ele): 87 | eleTitle=ele["title"] 88 | eleGroup = ele.get("releaseGroup") or guessit(eleTitle).get("release_group") 89 | if eleGroup and matchGroup: 90 | matchGroup=titleCleanUp(matchGroup) 91 | eleGroup=titleCleanUp(eleGroup) 92 | if jellyfish.jaro_distance(matchGroup, eleGroup) < .95: 93 | return False 94 | console.logging.info(f"{ele['title']} title matches") 95 | return True 96 | elif not eleGroup and not matchGroup: 97 | console.logging.info(f"{ele['title']} title matches") 98 | return True 99 | else: 100 | return False 101 | def titleCleanUp(title): 102 | 103 | title=re.sub(" +","",title) 104 | title=re.sub("\[","",title) 105 | title=re.sub("\]","",title) 106 | title=re.sub("\+","",title) 107 | title=re.sub("\*","",title) 108 | title=re.sub("[.-]","",title) 109 | title=title.lower() 110 | return title 111 | 112 | 113 | def matchQuality(matchQuality,ele): 114 | if guessit(ele["title"]).get("source")==matchQuality: 115 | return True 116 | return False 117 | 118 | 119 | def matchRes(res,ele): 120 | if not res and not guessit(ele["title"]).get("screen_size"): 121 | return True 122 | elif res==guessit(ele["title"]).get("screen_size"): 123 | return True 124 | return False 125 | def matchVideoCodec(codec,ele): 126 | if not codec and not guessit(ele["title"]).get("video_codec"): 127 | return True 128 | elif codec==guessit(ele["title"]).get("video_codec"): 129 | return True 130 | return False 131 | 132 | def matchVideoProfile(profile,ele): 133 | if not profile and not guessit(ele["title"]).get("video_profile"): 134 | return True 135 | elif profile==guessit(ele["title"]).get("video_profile"): 136 | return True 137 | return False 138 | 139 | 140 | def matchSpecial(matchTitle,ele): 141 | if re.search("proper",matchTitle,re.IGNORECASE) and not re.search("proper", ele["title"],re.IGNORECASE): 142 | return False 143 | elif re.search("repack",matchTitle,re.IGNORECASE) and not re.search("repack", ele["title"],re.IGNORECASE): 144 | return False 145 | return True 146 | 147 | def matchSeasonNum(inputList,matchSeason): 148 | return list(filter(lambda x:guessit(x["title"]).get("season")==matchSeason,inputList)) 149 | 150 | 151 | 152 | 153 | -------------------------------------------------------------------------------- /arr/radarr.py: -------------------------------------------------------------------------------- 1 | from pyarr import RadarrAPI 2 | from guessit import guessit 3 | 4 | import arr.filter as radarrFilter 5 | from arr.base import Base 6 | import console 7 | 8 | 9 | class Radarr(Base): 10 | def __init__(self): 11 | super().__init__() 12 | self.url = self.args.radarr.url 13 | self.api = self.args.radarr.api 14 | self.folder = self.args.radarr.folder 15 | self.radarr = RadarrAPI(self.url, self.api) 16 | 17 | 18 | 19 | ################################################################### 20 | #Process Functions 21 | ##################################################################### 22 | 23 | def _findCrossSeeds(self): 24 | with self.live: 25 | movies=self._getMovies() 26 | self.overallProgress.update(self.mediaTask,total=len(movies)) 27 | for movie in movies: 28 | self.overallProgress.update(self.mediaTask,description=f"Overall Media Progress: {movie['title']}") 29 | self._matchMovie(movie) 30 | self.overallProgress.update(self.mediaTask,description=f"Overall Media Progress: ",advance=1) 31 | 32 | def _matchMovie(self, movie): 33 | downloadList = self._getDownloads(movie) 34 | downloadList=list(map(lambda x:self._normalizeObj(x),downloadList)) 35 | releasesList = self._getReleases(f"{movie['title']} {movie['year']}") 36 | self.overallProgress.update(self.releaseTask,description=f"",total=len(downloadList)) 37 | for ele in downloadList: 38 | console.logging.info(f"Searching for match {ele}") 39 | self.overallProgress.update(self.releaseTask,description=f"{movie['title']} Progress: Looking for matching releases for {ele['title']}") 40 | matchingReleases=self._verifyMatches(releasesList,ele) 41 | for release in matchingReleases: 42 | self._downloadItem(release) 43 | self.overallProgress.update(self.releaseTask,description=f"Current Movie Progress: ",advance=1) 44 | 45 | def _verifyMatches(self,releasesList,prevDownload): 46 | filtered=list(filter(lambda x: radarrFilter.filterMovieReleases(x,prevDownload),releasesList)) 47 | console.logging.info(f"Matches: {filtered}") 48 | return filtered 49 | 50 | ################################################################### 51 | #Process Helpers 52 | ##################################################################### 53 | 54 | def _setArrDataNormalizers(self): 55 | super()._setArrDataNormalizers() 56 | self.arrID=lambda x:x["movieId"] 57 | self.arrLang=lambda x:x["languages"][0]["name"] 58 | 59 | 60 | ############################################################################ 61 | # 62 | # Data Retrivers 63 | ############################################################################## 64 | 65 | def _getMovies(self): 66 | self.movieList = self.radarr.get_movie() or [] 67 | return self.movieList 68 | 69 | 70 | def _getDownloads(self, movie): 71 | downloadList=None 72 | if self.flag == "grabbed": 73 | downloadList = self._getGrabs(movie) 74 | elif self.flag == "imported": 75 | downloadList = self._getImported(movie) 76 | [ele["sourceTitle"]==self._titleSimplify(ele["sourceTitle"]) for ele in downloadList] 77 | return downloadList 78 | 79 | def _getGrabs(self, movie): 80 | grabHistory=self.radarr.get_movie_history(movie.get("id"), event_type="grabbed") 81 | uniqueTitles=set(list(map(lambda x:x["sourceTitle"],grabHistory))) 82 | temp=[] 83 | for ele in grabHistory: 84 | if ele["sourceTitle"] in uniqueTitles: 85 | temp.append(ele) 86 | uniqueTitles.remove(ele["sourceTitle"]) 87 | return temp 88 | 89 | 90 | def _getImported(self, movie): 91 | grabHistory=self._getGrabs(movie) 92 | importHistory = self.radarr.get_movie_history(movie.get("id"), event_type="downloadFolderImported") 93 | deletedHistory = self.radarr.get_movie_history(movie.get("id"), event_type="movieFileDeleted") 94 | titleSet=set(list(map(lambda x:x["sourceTitle"],importHistory))+list(map(lambda x:x["sourceTitle"],deletedHistory))) 95 | return list(filter(lambda x:x["sourceTitle"] in titleSet,grabHistory)) 96 | 97 | 98 | 99 | def _getReleases(self, title): 100 | 101 | query=f"{title}" 102 | url=f"{self.args.prowlarrurl}/api/v1/search" 103 | params={"apikey":self.args.prowlarrapi,"indexerIDs":self.indexerIDs,"query":query,"limit":1000,"categories":"2000","type":"movie"} 104 | req=self.session.get(url,params=params) 105 | if req.status_code!=200: 106 | self._rowHelper() 107 | msg=f"Req to {req.url} was not successful with status code {req.status_code}" 108 | self.messageTable.add_row("Error",msg,style="red") 109 | console.logging.error(msg) 110 | self.errorReq=self.errorReq+1 111 | return [] 112 | else: 113 | self._rowHelper() 114 | msg=f"Req to {req.url} was successful with status code {req.status_code}" 115 | self.messageTable.add_row("Request",msg,style="green") 116 | console.logging.info(msg) 117 | self.successReq=self.successReq+1 118 | data=req.json() 119 | 120 | data=list(filter(lambda x: radarrFilter.matchDate(self.currDate,x),data)) 121 | return data 122 | 123 | 124 | 125 | def _setProgressBars(self): 126 | super()._setProgressBars() 127 | self.mediaTask=self.overallProgress.add_task(f"Overall Media Progress: ") 128 | self.releaseTask=self.overallProgress.add_task(f"Current Movie Progress:") 129 | -------------------------------------------------------------------------------- /arr/sonarr.py: -------------------------------------------------------------------------------- 1 | from pyarr import SonarrAPI 2 | from guessit import guessit 3 | 4 | import arr.filter as sonarrFilter 5 | import console 6 | from arr.base import Base 7 | 8 | 9 | 10 | 11 | 12 | class Sonarr(Base): 13 | def __init__(self): 14 | super().__init__() 15 | 16 | self.url = self.args.sonarr.url 17 | self.api = self.args.sonarr.api 18 | self.folder = self.args.sonarr.folder 19 | self.sonarr = SonarrAPI(self.url, self.api) 20 | 21 | 22 | 23 | ############################################################################ 24 | # 25 | # Process Functions 26 | ############################################################################## 27 | 28 | 29 | 30 | def _findCrossSeeds(self): 31 | ''' 32 | processes the Cross-seeding request for user 33 | 34 | Parameters: 35 | NONE 36 | 37 | Returns: 38 | None 39 | ''' 40 | 41 | shows=self._getShows() 42 | with self.live: 43 | self.overallProgress.update(self.mediaTask,description=f"Overall Media Progress:",total=len(shows)) 44 | for show in shows: 45 | self.overallProgress.update(self.mediaTask,description=f"Overall Media Progress: Searching -> {show['title']}") 46 | self._processShow(show) 47 | self.overallProgress.update(self.mediaTask,advance=1) 48 | 49 | 50 | 51 | def _processShow(self,show): 52 | id=show["id"] 53 | showTitle=show["title"] 54 | i=1 55 | self.overallProgress.update(self.seasonTask,description=f"{show['title']} Progress: ",total=len(show["seasons"])-1,completed=0) 56 | 57 | while i=', 0), ('<=', 44640)]) 13 | def setupConfig(p): 14 | if not Docker_KEY: 15 | defaultconfigPath = os.path.join(defaults.getHomeDir(),"config" ,"config.json") 16 | p.default_config_files = [defaultconfigPath] 17 | p.add_argument("-c","--config",action=ActionConfigFile) 18 | else: 19 | defaultconfigPath ="/config/config.json" 20 | p.default_config_files = [defaultconfigPath] 21 | return p 22 | 23 | def postSetup(r): 24 | r.clientname=r.clientname or defaults.getClientName(r) 25 | r.outputs=defaults.getOutputFolder(r) 26 | return r 27 | 28 | 29 | 30 | 31 | def getArgs(): 32 | p = ArgumentParser(prog="crossarr") 33 | p=setupConfig(p) 34 | 35 | p.add_argument('-n', '--clientname', help="Name of client use for logfile/lockfile\nsonarr or raddarr use if not set",required=False) 36 | p.add_argument('-v', '--loglevel', help="what level to set log to flexible case-senstivity main options are [DEBUG,INFO,OFF]",type=restricted_string_type('LOG Names', '(off|debug|info)'),default="OFF") 37 | p.add_argument("-r","--rows",help="Advanced Feature to set how many table rows to render for Messages") 38 | p.add_argument("-t","--threshold",type=int,default=1,help="The max size difference \% a match can have") 39 | p.add_argument('-y', '--days', type=int,default=99999999999999999999,help="Max Age of a release in days") 40 | p.add_argument('-a', '--prowlarrapi', help="Prowlar API key") 41 | p.add_argument('-p', '--prowlarrurl', help="Prowlar URL") 42 | p.add_argument('-d', '--dryrun', help="Don't download matches", action='store_true') 43 | p.add_argument("-f","--flag", choices=["grabbed", "imported"],default="imported", 44 | help= \ 45 | """ 46 | grabbed= Releases grabbed and added to any client 47 | imported= Releases completed, and imported into library 48 | """ 49 | ) 50 | p.add_argument("-it","--interval",default=defaults.getDefaultInterval(),help="Run the script every x minutes, 0 turns off",type=interval,metavar="[0-44640]") 51 | p.add_argument("-i",'--indexers', nargs="+",help="Names of Indexer\nUses Regex to Match Names") 52 | 53 | radarr=ArgumentParser() 54 | radarr.add_argument('-a', '--api',help="radarr apikey",required=True) 55 | radarr.add_argument('-u', '--url',help="radarr url",required=True) 56 | radarr.add_argument('-f', '--folder', help="Where to save radarr torrents",required=True) 57 | 58 | sonarr = ArgumentParser() 59 | sonarr.add_argument('-a', '--api',help="sonarr api key",required=True) 60 | sonarr.add_argument('-u', '--url',help="sonarr apikey",required=True) 61 | sonarr.add_argument('-f', '--folder', help="Where to save sonarr torrents",required=True) 62 | 63 | subcommands = p.add_subcommands(help="Whether you want to scan sonarr or radarr") 64 | subcommands.add_subcommand('radarr', radarr) 65 | subcommands.add_subcommand('sonarr', sonarr) 66 | 67 | 68 | 69 | r=p.parse_args() 70 | return postSetup(r) 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /setup/defaults.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | Docker_KEY = os.environ.get('CROSSARR_DOCKER', False) 4 | 5 | 6 | def getClientName(r): 7 | return os.environ.get('CROSSARR_CLIENT') or r.clientname or f"{r.subcommand}.log" 8 | 9 | def getLogFolder(): 10 | if Docker_KEY: 11 | return "/logs" 12 | else: 13 | return os.path.join(getHomeDir(),"logs") 14 | 15 | def getHomeDir(): 16 | return pathlib.Path(os.path.realpath(__file__)).parents[1] 17 | def getOutputFolder(r): 18 | 19 | if Docker_KEY: 20 | return "/output" 21 | return r[r.subcommand].folder 22 | def setupLockFolder(r): 23 | return os.path.join(pathlib.Path(os.path.realpath(__file__)).parents[1]) 24 | 25 | def getDefaultInterval(): 26 | if os.environ.get('CROSSARR_INTERVAL'): 27 | return int(os.environ.get('CROSSARR_INTERVAL')) 28 | return 0 -------------------------------------------------------------------------------- /setup/logger.py: -------------------------------------------------------------------------------- 1 | import console 2 | import setup.args as args 3 | import re 4 | import os 5 | import pathlib 6 | import setup.defaults as defaults 7 | userargs=args.getArgs() 8 | def logfilter(record): 9 | record.msg=re.sub(userargs.prowlarrapi,"prowlarrapikey",record.msg) 10 | if userargs.get("sonarr"): 11 | record.msg=re.sub(userargs.sonarr.api,"radarrapikey",record.msg) 12 | else: 13 | record.msg=re.sub(userargs.radarr.api,"sonarrapikey",record.msg) 14 | return True 15 | def nologfilter(record): 16 | return False 17 | 18 | def setupLogging(): 19 | if userargs.loglevel.upper()!="OFF": 20 | console.logging.getLogger(name=None).filter=logfilter 21 | console.logging.basicConfig(filename=os.path.join(defaults.getLogFolder(),userargs.clientname) ,level=getattr(console.logging, userargs.loglevel.upper()),format='%(asctime)s:%(levelname)s:%(message)s',datefmt='%Y-%m-%d %H:%M:%S') 22 | else: 23 | console.logging.getLogger(name=None).filter=nologfilter 24 | --------------------------------------------------------------------------------