├── .devcontainer └── devcontainer.json ├── .github └── workflows │ └── main.yaml ├── .gitignore ├── .vscode └── launch.json ├── Dockerfile ├── README.md ├── app ├── config.yml.template ├── sonarr_youtubedl.py └── utils.py └── requirements.txt /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sonarr_youtubedl", 3 | "context": "..", 4 | "dockerFile": "..\\Dockerfile", 5 | "settings": { 6 | "terminal.integrated.shell.linux": "/bin/bash", 7 | "python.pythonPath": "/usr/local/bin/python", 8 | "python.linting.enabled": true, 9 | "python.linting.pylintEnabled": false, 10 | "python.linting.flake8Enabled": true, 11 | "python.linting.flake8Path": "/usr/local/bin/flake8", 12 | "cornflakes.linter.executablePath": "/usr/local/bin/flake8" 13 | }, 14 | "extensions": [ 15 | "ms-python.python", 16 | "kevinglasson.cornflakes-linter" 17 | ], 18 | "containerEnv": { 19 | "CONFIGPATH": "/config/config.yml" 20 | }, 21 | "mounts": [ 22 | "source=${localWorkspaceFolder}/config/config.yml,target=/config/config.yml,type=bind", 23 | "source=${localWorkspaceFolder}/config/youtube_cookies.txt,target=/config/youtube_cookies.txt,type=bind", 24 | "source=${localWorkspaceFolder}/app/sonarr_root/,target=/sonarr_root/,type=bind" 25 | 26 | ] 27 | } -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | - development 9 | tags: 10 | - "*" 11 | 12 | jobs: 13 | docker: 14 | name: Docker 15 | runs-on: ubuntu-latest 16 | defaults: 17 | run: 18 | shell: bash 19 | steps: 20 | 21 | # https://github.com/marketplace/actions/checkout 22 | - name: Checkout 23 | uses: actions/checkout@v3 24 | 25 | # https://github.com/docker/login-action#docker-hub 26 | - name: Login to Docker Hub 27 | uses: docker/login-action@v2 28 | with: 29 | registry: docker.io 30 | username: ${{ secrets.DOCKER_USERNAME }} 31 | password: ${{ secrets.DOCKER_TOKEN }} 32 | 33 | - name: Set Docker Tag 34 | id: tag 35 | run: | 36 | if [[ $GITHUB_REF == refs/tags/* ]]; then 37 | DOCKER_TAG="${GITHUB_REF:10}" 38 | elif [[ $GITHUB_REF == refs/heads/development ]]; then 39 | DOCKER_TAG="dev" 40 | elif [[ $GITHUB_REF == refs/heads/main ]]; then 41 | DOCKER_TAG="latest" 42 | else 43 | DOCKER_TAG="${GITHUB_REF:11}" 44 | fi 45 | echo ::set-output name=tag::${DOCKER_TAG} 46 | 47 | # https://github.com/docker/setup-qemu-action 48 | - name: Set up QEMU 49 | uses: docker/setup-qemu-action@v2.1.0 50 | 51 | # https://github.com/docker/setup-buildx-action 52 | - name: Set up Docker Buildx 53 | uses: docker/setup-buildx-action@v2.2.1 54 | 55 | # https://github.com/docker/build-push-action 56 | - name: Build and Push Docker Image 57 | if: github.ref == 'refs/heads/development' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') 58 | id: docker_build 59 | uses: docker/build-push-action@v3.3.0 60 | with: 61 | context: . 62 | push: true 63 | file: ./Dockerfile 64 | platforms: linux/386,linux/amd64,linux/arm/v7,linux/arm64/v8 65 | tags: whatdaybob/sonarr_youtubedl:${{ steps.tag.outputs.tag }} 66 | labels: ${{ steps.meta.outputs.labels }} 67 | cache-from: type=gha 68 | cache-to: type=gha,mode=max -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Local testing files 2 | env/ 3 | __pycache__/ 4 | *.backup 5 | .venv 6 | *_cookies.txt 7 | sonarr_root/ 8 | config/config.yml 9 | config/config.yml.template 10 | logs/ 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Devcontainer: Launch sonarr_youtubedl", 6 | "type": "python", 7 | "request": "launch", 8 | "program": "app/sonarr_youtubedl.py", 9 | "console": "integratedTerminal" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-buster 2 | LABEL maintainer="Martin Jones " 3 | 4 | # Update and install ffmpeg 5 | RUN apt-get update && \ 6 | apt-get install -y ffmpeg 7 | 8 | # Copy and install requirements 9 | COPY requirements.txt requirements.txt 10 | RUN pip3 install -r requirements.txt 11 | 12 | # create abc user so root isn't used 13 | RUN \ 14 | groupmod -g 1000 users && \ 15 | useradd -u 911 -U -d /config -s /bin/false abc && \ 16 | usermod -G users abc && \ 17 | # create some files / folders 18 | mkdir -p /config /app /sonarr_root /logs && \ 19 | touch /var/lock/sonarr_youtube.lock 20 | 21 | # add volumes 22 | VOLUME /config 23 | VOLUME /sonarr_root 24 | VOLUME /logs 25 | 26 | # add local files 27 | COPY app/ /app 28 | 29 | # update file permissions 30 | RUN \ 31 | chmod a+x \ 32 | /app/sonarr_youtubedl.py \ 33 | /app/utils.py \ 34 | /app/config.yml.template 35 | 36 | # ENV setup 37 | ENV CONFIGPATH /config/config.yml 38 | 39 | CMD [ "python", "-u", "/app/sonarr_youtubedl.py" ] 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sonarr_youtubedl by [@whatdaybob](https://github.com/whatdaybob) 2 | 3 | ![Docker Build](https://img.shields.io/docker/cloud/automated/whatdaybob/sonarr_youtubedl?style=flat-square) 4 | ![Docker Pulls](https://img.shields.io/docker/pulls/whatdaybob/sonarr_youtubedl?style=flat-square) 5 | ![Docker Stars](https://img.shields.io/docker/stars/whatdaybob/sonarr_youtubedl?style=flat-square) 6 | [![Docker Hub](https://img.shields.io/badge/Open%20On-DockerHub-blue)](https://hub.docker.com/r/whatdaybob/sonarr_youtubedl) 7 | 8 | [whatdaybob/sonarr_youtubedl](https://github.com/whatdaybob/Custom_Docker_Images/tree/master/sonarr_youtubedl) is a [Sonarr](https://sonarr.tv/) companion script to allow the automatic downloading of web series normally not available for Sonarr to search for. Using [YT-DLP](https://github.com/yt-dlp/yt-dlp) (a youtube-dl fork with added features) it allows you to download your webseries from the list of [supported sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md). 9 | 10 | ## Features 11 | 12 | * Downloading **Web Series** using online sources normally unavailable to Sonarr 13 | * Ability to specify the downloaded video format globally or per series 14 | * Downloads new episodes automatically once available 15 | * Imports directly to Sonarr and it can then update your plex as and example 16 | * Allows setting time offsets to handle prerelease series 17 | * Can pass cookies.txt to handle site logins 18 | 19 | ## How do I use it 20 | 21 | Firstly you need a series that is available online in the supported sites that YouTube-DL can grab from. 22 | Secondly you need to add this to Sonarr and monitor the episodes that you want. 23 | Thirdly edit your config.yml accordingly so that this knows where your Sonarr is, which series you are after and where to grab it from. 24 | Lastly be aware that this requires the TVDB to match exactly what the episodes titles are in the scan, generally this is ok but as its an openly editable site sometime there can be differences. 25 | 26 | ## Supported Architectures 27 | 28 | The architectures supported by this image are: 29 | 30 | | Architecture | Tag | 31 | | :----: | --- | 32 | | x86-64 | latest | 33 | | x86-64 | dev | 34 | 35 | ## Version Tags 36 | 37 | | Tag | Description | 38 | | :----: | --- | 39 | | latest | Current release code | 40 | | dev | Pre-release code for testing issues | 41 | 42 | ## Great how do I get started 43 | 44 | Obviously its a docker image so you need docker, if you don't know what that is you need to look into that first. 45 | 46 | ### docker 47 | 48 | ```bash 49 | docker create \ 50 | --name=sonarr_youtubedl \ 51 | -v /path/to/data:/config \ 52 | -v /path/to/sonarrmedia:/sonarr_root \ 53 | -v /path/to/logs:/logs \ 54 | --restart unless-stopped \ 55 | whatdaybob/sonarr_youtubedl 56 | ``` 57 | 58 | ### docker-compose 59 | 60 | ```yaml 61 | --- 62 | version: '3.4' 63 | services: 64 | sonarr_youtubedl: 65 | image: whatdaybob/sonarr_youtubedl 66 | container_name: sonarr_youtubedl 67 | volumes: 68 | - /path/to/data:/config 69 | - /path/to/sonarrmedia:/sonarr_root 70 | - /path/to/logs:/logs 71 | ``` 72 | 73 | ### Docker volumes 74 | 75 | | Parameter | Function | 76 | | :----: | --- | 77 | | `-v /config` | sonarr_youtubedl configs | 78 | | `-v /sonarr_root` | Root library location from Sonarr container | 79 | | `-v /logs` | log location | 80 | 81 | **Clarification on sonarr_root** 82 | 83 | A couple of people are not sure what is meant by the sonarr root. As this downloads directly to where you media is stored I mean the root folder where sonarr will place the files. So in sonarr you have your files moving to `/mnt/sda1/media/tv/Smarter Every Day/` as an example, in sonarr you will see that it saves this series to `/tv/Smarter Every Day/` meaning the sonarr root is `/mnt/sda1/media/` as this is the root folder sonarr is working from. 84 | 85 | ## Configuration file 86 | 87 | On first run the docker will create a template file in the config folder. Example [config.yml.template](./app/config.yml.template) 88 | 89 | Copy the `config.yml.template` to a new file called `config.yml` and edit accordingly. 90 | 91 | If I helped in anyway and you would like to help me, consider donating a lovely beverage with the below. 92 | 93 | 94 | Buy Me A Coffee 95 | -------------------------------------------------------------------------------- /app/config.yml.template: -------------------------------------------------------------------------------- 1 | sonarrytdl: 2 | scan_interval: 1 # minutes between scans 3 | debug: False # Set to True for a more verbose output 4 | sonarr: 5 | host: 192.168.1.123 6 | port: 8989 # sonarr default port 7 | apikey: 12341234 8 | ssl: false 9 | # basedir: '/sonarr' # if you have sonarr running with a basedir set (e.g. behind a proxy) 10 | # version: v4 # if running v4 beta, allows the v3 api endpoints 11 | 12 | ytdl: 13 | # For information on format refer to https://github.com/ytdl-org/youtube-dl#format-selection 14 | default_format: bestvideo[width<=1920]+bestaudio/best[width<=1920] 15 | series: 16 | # Standard channel to check 17 | - title: Smarter Every Day 18 | url: https://www.youtube.com/channel/UC6107grRI4m0o2-emgoDnAA 19 | # Example using cookies file and custom format 20 | # For information on cookies refer to https://github.com/ytdl-org/youtube-dl#how-do-i-pass-cookies-to-youtube-dl 21 | # For information on format refer to https://github.com/ytdl-org/youtube-dl#format-selection 22 | - title: The Slow Mo Guys 23 | url: https://www.youtube.com/channel/UCUK0HBIBWgM2c4vsPhkYY4w 24 | cookies_file: youtube_cookies.txt # located in the same config folder 25 | format: bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best 26 | # Youtube playlist of latest season with time offset, useful for member videos having early release 27 | - title: CHUMP 28 | url: https://www.youtube.com/playlist?list=PLUBVPK8x-XMiVzV098TtYq55awkA2XmXm 29 | offset: 30 | days: 2 31 | hours: 3 32 | regex: 33 | sonarr: 34 | match: '.-.#[0-9]*$' 35 | replace: '' 36 | # Youtube playlist with subtitles or autogenerated subtitles overriding the default reverse order 37 | - title: Ready Set Show 38 | url: https://www.youtube.com/playlist?list=PLTur7oukosPEwFTPJ1WeDvitauWzRiIhp 39 | playlistreverse: False 40 | subtitles: 41 | languages: ['en'] 42 | autogenerated: True 43 | -------------------------------------------------------------------------------- /app/sonarr_youtubedl.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import urllib.parse 3 | import yt_dlp 4 | import os 5 | import sys 6 | import re 7 | from utils import upperescape, checkconfig, offsethandler, YoutubeDLLogger, ytdl_hooks, ytdl_hooks_debug, setup_logging # NOQA 8 | from datetime import datetime 9 | import schedule 10 | import time 11 | import logging 12 | import argparse 13 | 14 | # allow debug arg for verbose logging 15 | parser = argparse.ArgumentParser(description='Process some integers.') 16 | parser.add_argument('--debug', action='store_true', help='Enable debug logging') 17 | args = parser.parse_args() 18 | 19 | # setup logger 20 | logger = setup_logging(True, True, args.debug) 21 | 22 | date_format = "%Y-%m-%dT%H:%M:%SZ" 23 | now = datetime.now() 24 | 25 | CONFIGFILE = os.environ['CONFIGPATH'] 26 | CONFIGPATH = CONFIGFILE.replace('config.yml', '') 27 | SCANINTERVAL = 60 28 | 29 | 30 | class SonarrYTDL(object): 31 | 32 | def __init__(self): 33 | """Set up app with config file settings""" 34 | cfg = checkconfig() 35 | 36 | # Sonarr_YTDL Setup 37 | 38 | try: 39 | self.set_scan_interval(cfg['sonarrytdl']['scan_interval']) 40 | try: 41 | self.debug = cfg['sonarrytdl']['debug'] in ['true', 'True'] 42 | if self.debug: 43 | logger.setLevel(logging.DEBUG) 44 | for logs in logger.handlers: 45 | if logs.name == 'FileHandler': 46 | logs.setLevel(logging.DEBUG) 47 | if logs.name == 'StreamHandler': 48 | logs.setLevel(logging.DEBUG) 49 | logger.debug('DEBUGGING ENABLED') 50 | except AttributeError: 51 | self.debug = False 52 | except Exception: 53 | sys.exit("Error with sonarrytdl config.yml values.") 54 | 55 | # Sonarr Setup 56 | try: 57 | api = "api" 58 | scheme = "http" 59 | basedir = "" 60 | if cfg['sonarr'].get('version', '').lower() == 'v4': 61 | api = "api/v3" 62 | logger.debug('Sonarr api set to v4') 63 | if cfg['sonarr']['ssl'].lower() == 'true': 64 | scheme = "https" 65 | if cfg['sonarr'].get('basedir', ''): 66 | basedir = '/' + cfg['sonarr'].get('basedir', '') 67 | 68 | self.base_url = "{0}://{1}:{2}{3}".format( 69 | scheme, 70 | cfg['sonarr']['host'], 71 | str(cfg['sonarr']['port']), 72 | basedir 73 | ) 74 | self.sonarr_api_version = api 75 | self.api_key = cfg['sonarr']['apikey'] 76 | except Exception: 77 | sys.exit("Error with sonarr config.yml values.") 78 | 79 | # YTDL Setup 80 | try: 81 | self.ytdl_format = cfg['ytdl']['default_format'] 82 | except Exception: 83 | sys.exit("Error with ytdl config.yml values.") 84 | 85 | # YTDL Setup 86 | try: 87 | self.series = cfg["series"] 88 | except Exception: 89 | sys.exit("Error with series config.yml values.") 90 | 91 | def get_episodes_by_series_id(self, series_id): 92 | """Returns all episodes for the given series""" 93 | logger.debug('Begin call Sonarr for all episodes for series_id: {}'.format(series_id)) 94 | args = {'seriesId': series_id} 95 | res = self.request_get("{}/{}/episode".format( 96 | self.base_url, 97 | self.sonarr_api_version 98 | ), args 99 | ) 100 | return res.json() 101 | 102 | def get_episode_files_by_series_id(self, series_id): 103 | """Returns all episode files for the given series""" 104 | res = self.request_get("{}/{}/episodefile?seriesId={}".format( 105 | self.base_url, 106 | self.sonarr_api_version, 107 | series_id 108 | )) 109 | return res.json() 110 | 111 | def get_series(self): 112 | """Return all series in your collection""" 113 | logger.debug('Begin call Sonarr for all available series') 114 | res = self.request_get("{}/{}/series".format( 115 | self.base_url, 116 | self.sonarr_api_version 117 | )) 118 | return res.json() 119 | 120 | def get_series_by_series_id(self, series_id): 121 | """Return the series with the matching ID or 404 if no matching series is found""" 122 | logger.debug('Begin call Sonarr for specific series series_id: {}'.format(series_id)) 123 | res = self.request_get("{}/{}/series/{}".format( 124 | self.base_url, 125 | self.sonarr_api_version, 126 | series_id 127 | )) 128 | return res.json() 129 | 130 | def request_get(self, url, params=None): 131 | """Wrapper on the requests.get""" 132 | logger.debug('Begin GET with url: {}'.format(url)) 133 | args = { 134 | "apikey": self.api_key 135 | } 136 | if params is not None: 137 | logger.debug('Begin GET with params: {}'.format(params)) 138 | args.update(params) 139 | url = "{}?{}".format( 140 | url, 141 | urllib.parse.urlencode(args) 142 | ) 143 | res = requests.get(url) 144 | return res 145 | 146 | def request_put(self, url, params=None, jsondata=None): 147 | logger.debug('Begin PUT with url: {}'.format(url)) 148 | """Wrapper on the requests.put""" 149 | headers = { 150 | 'Content-Type': 'application/json', 151 | } 152 | args = ( 153 | ('apikey', self.api_key), 154 | ) 155 | if params is not None: 156 | args.update(params) 157 | logger.debug('Begin PUT with params: {}'.format(params)) 158 | res = requests.post( 159 | url, 160 | headers=headers, 161 | params=args, 162 | json=jsondata 163 | ) 164 | return res 165 | 166 | def rescanseries(self, series_id): 167 | """Refresh series information from trakt and rescan disk""" 168 | logger.debug('Begin call Sonarr to rescan for series_id: {}'.format(series_id)) 169 | data = { 170 | "name": "RescanSeries", 171 | "seriesId": str(series_id) 172 | } 173 | res = self.request_put( 174 | "{}/{}/command".format(self.base_url, self.sonarr_api_version), 175 | None, 176 | data 177 | ) 178 | return res.json() 179 | 180 | def filterseries(self): 181 | """Return all series in Sonarr that are to be downloaded by youtube-dl""" 182 | series = self.get_series() 183 | matched = [] 184 | for ser in series[:]: 185 | for wnt in self.series: 186 | if wnt['title'] == ser['title']: 187 | # Set default values 188 | ser['subtitles'] = False 189 | ser['playlistreverse'] = True 190 | ser['subtitles_languages'] = ['en'] 191 | ser['subtitles_autogenerated'] = False 192 | # Update values 193 | if 'regex' in wnt: 194 | regex = wnt['regex'] 195 | if 'sonarr' in regex: 196 | ser['sonarr_regex_match'] = regex['sonarr']['match'] 197 | ser['sonarr_regex_replace'] = regex['sonarr']['replace'] 198 | if 'site' in regex: 199 | ser['site_regex_match'] = regex['site']['match'] 200 | ser['site_regex_replace'] = regex['site']['replace'] 201 | if 'offset' in wnt: 202 | ser['offset'] = wnt['offset'] 203 | if 'cookies_file' in wnt: 204 | ser['cookies_file'] = wnt['cookies_file'] 205 | if 'format' in wnt: 206 | ser['format'] = wnt['format'] 207 | if 'playlistreverse' in wnt: 208 | if wnt['playlistreverse'] == 'False': 209 | ser['playlistreverse'] = False 210 | if 'subtitles' in wnt: 211 | ser['subtitles'] = True 212 | if 'languages' in wnt['subtitles']: 213 | ser['subtitles_languages'] = wnt['subtitles']['languages'] 214 | if 'autogenerated' in wnt['subtitles']: 215 | ser['subtitles_autogenerated'] = wnt['subtitles']['autogenerated'] 216 | ser['url'] = wnt['url'] 217 | matched.append(ser) 218 | for check in matched: 219 | if not check['monitored']: 220 | logger.warn('{0} is not currently monitored'.format(ser['title'])) 221 | del series[:] 222 | return matched 223 | 224 | def getseriesepisodes(self, series): 225 | needed = [] 226 | for ser in series[:]: 227 | episodes = self.get_episodes_by_series_id(ser['id']) 228 | for eps in episodes[:]: 229 | eps_date = now 230 | if "airDateUtc" in eps: 231 | eps_date = datetime.strptime(eps['airDateUtc'], date_format) 232 | if 'offset' in ser: 233 | eps_date = offsethandler(eps_date, ser['offset']) 234 | if not eps['monitored']: 235 | episodes.remove(eps) 236 | elif eps['hasFile']: 237 | episodes.remove(eps) 238 | elif eps_date > now: 239 | episodes.remove(eps) 240 | else: 241 | if 'sonarr_regex_match' in ser: 242 | match = ser['sonarr_regex_match'] 243 | replace = ser['sonarr_regex_replace'] 244 | eps['title'] = re.sub(match, replace, eps['title']) 245 | needed.append(eps) 246 | continue 247 | if len(episodes) == 0: 248 | logger.info('{0} no episodes needed'.format(ser['title'])) 249 | series.remove(ser) 250 | else: 251 | logger.info('{0} missing {1} episodes'.format( 252 | ser['title'], 253 | len(episodes) 254 | )) 255 | for i, e in enumerate(episodes): 256 | logger.info(' {0}: {1} - {2}'.format( 257 | i + 1, 258 | ser['title'], 259 | e['title'] 260 | )) 261 | return needed 262 | 263 | def appendcookie(self, ytdlopts, cookies=None): 264 | """Checks if specified cookie file exists in config 265 | - ``ytdlopts``: Youtube-dl options to append cookie to 266 | - ``cookies``: filename of cookie file to append to Youtube-dl opts 267 | returns: 268 | ytdlopts 269 | original if problem with cookies file 270 | updated with cookies value if cookies file exists 271 | """ 272 | if cookies is not None: 273 | cookie_path = os.path.abspath(CONFIGPATH + cookies) 274 | cookie_exists = os.path.exists(cookie_path) 275 | if cookie_exists is True: 276 | ytdlopts.update({ 277 | 'cookiefile': cookie_path 278 | }) 279 | # if self.debug is True: 280 | logger.debug(' Cookies file used: {}'.format(cookie_path)) 281 | if cookie_exists is False: 282 | logger.warning(' cookie files specified but doesn''t exist.') 283 | return ytdlopts 284 | else: 285 | return ytdlopts 286 | 287 | def customformat(self, ytdlopts, customformat=None): 288 | """Checks if specified cookie file exists in config 289 | - ``ytdlopts``: Youtube-dl options to change the ytdl format for 290 | - ``customformat``: format to download 291 | returns: 292 | ytdlopts 293 | original: if no custom format 294 | updated: with new format value if customformat exists 295 | """ 296 | if customformat is not None: 297 | ytdlopts.update({ 298 | 'format': customformat 299 | }) 300 | return ytdlopts 301 | else: 302 | return ytdlopts 303 | 304 | def ytdl_eps_search_opts(self, regextitle, playlistreverse, cookies=None): 305 | ytdlopts = { 306 | 'ignoreerrors': True, 307 | 'playlistreverse': playlistreverse, 308 | 'matchtitle': regextitle, 309 | 'quiet': True, 310 | 311 | } 312 | if self.debug is True: 313 | ytdlopts.update({ 314 | 'quiet': False, 315 | 'logger': YoutubeDLLogger(), 316 | 'progress_hooks': [ytdl_hooks], 317 | }) 318 | ytdlopts = self.appendcookie(ytdlopts, cookies) 319 | if self.debug is True: 320 | logger.debug('Youtube-DL opts used for episode matching') 321 | logger.debug(ytdlopts) 322 | return ytdlopts 323 | 324 | def ytsearch(self, ydl_opts, playlist): 325 | try: 326 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 327 | result = ydl.extract_info( 328 | playlist, 329 | download=False 330 | ) 331 | except Exception as e: 332 | logger.error(e) 333 | else: 334 | video_url = None 335 | if 'entries' in result and len(result['entries']) > 0: 336 | try: 337 | video_url = result['entries'][0].get('webpage_url') 338 | except Exception as e: 339 | logger.error(e) 340 | else: 341 | video_url = result.get('webpage_url') 342 | if playlist == video_url: 343 | return False, '' 344 | if video_url is None: 345 | logger.error('No video_url') 346 | return False, '' 347 | else: 348 | return True, video_url 349 | 350 | def download(self, series, episodes): 351 | if len(series) != 0: 352 | logger.info("Processing Wanted Downloads") 353 | for s, ser in enumerate(series): 354 | logger.info(" {}:".format(ser['title'])) 355 | for e, eps in enumerate(episodes): 356 | if ser['id'] == eps['seriesId']: 357 | cookies = None 358 | url = ser['url'] 359 | if 'cookies_file' in ser: 360 | cookies = ser['cookies_file'] 361 | ydleps = self.ytdl_eps_search_opts(upperescape(eps['title']), ser['playlistreverse'], cookies) 362 | found, dlurl = self.ytsearch(ydleps, url) 363 | if found: 364 | logger.info(" {}: Found - {}:".format(e + 1, eps['title'])) 365 | ytdl_format_options = { 366 | 'format': self.ytdl_format, 367 | 'quiet': True, 368 | 'merge-output-format': 'mp4', 369 | 'outtmpl': '/sonarr_root{0}/Season {1}/{2} - S{1}E{3} - {4} WEBDL.%(ext)s'.format( 370 | ser['path'], 371 | eps['seasonNumber'], 372 | ser['title'], 373 | eps['episodeNumber'], 374 | eps['title'] 375 | ), 376 | 'progress_hooks': [ytdl_hooks], 377 | 'noplaylist': True, 378 | } 379 | ytdl_format_options = self.appendcookie(ytdl_format_options, cookies) 380 | if 'format' in ser: 381 | ytdl_format_options = self.customformat(ytdl_format_options, ser['format']) 382 | if 'subtitles' in ser: 383 | if ser['subtitles']: 384 | postprocessors = [] 385 | postprocessors.append({ 386 | 'key': 'FFmpegSubtitlesConvertor', 387 | 'format': 'srt', 388 | }) 389 | postprocessors.append({ 390 | 'key': 'FFmpegEmbedSubtitle', 391 | }) 392 | ytdl_format_options.update({ 393 | 'writesubtitles': True, 394 | 'allsubtitles': True, 395 | 'writeautomaticsub': True, 396 | 'subtitleslangs': ser['subtitles_languages'], 397 | 'postprocessors': postprocessors, 398 | }) 399 | 400 | 401 | if self.debug is True: 402 | ytdl_format_options.update({ 403 | 'quiet': False, 404 | 'logger': YoutubeDLLogger(), 405 | 'progress_hooks': [ytdl_hooks_debug], 406 | }) 407 | logger.debug('Youtube-DL opts used for downloading') 408 | logger.debug(ytdl_format_options) 409 | try: 410 | yt_dlp.YoutubeDL(ytdl_format_options).download([dlurl]) 411 | self.rescanseries(ser['id']) 412 | logger.info(" Downloaded - {}".format(eps['title'])) 413 | except Exception as e: 414 | logger.error(" Failed - {} - {}".format(eps['title'], e)) 415 | else: 416 | logger.info(" {}: Missing - {}:".format(e + 1, eps['title'])) 417 | else: 418 | logger.info("Nothing to process") 419 | 420 | def set_scan_interval(self, interval): 421 | global SCANINTERVAL 422 | if interval != SCANINTERVAL: 423 | SCANINTERVAL = interval 424 | logger.info('Scan interval set to every {} minutes by config.yml'.format(interval)) 425 | else: 426 | logger.info('Default scan interval of every {} minutes in use'.format(interval)) 427 | return 428 | 429 | 430 | def main(): 431 | client = SonarrYTDL() 432 | series = client.filterseries() 433 | episodes = client.getseriesepisodes(series) 434 | client.download(series, episodes) 435 | logger.info('Waiting...') 436 | 437 | 438 | if __name__ == "__main__": 439 | logger.info('Initial run') 440 | main() 441 | schedule.every(int(SCANINTERVAL)).minutes.do(main) 442 | while True: 443 | schedule.run_pending() 444 | time.sleep(1) 445 | -------------------------------------------------------------------------------- /app/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | import sys 4 | import datetime 5 | import yaml 6 | import logging 7 | from logging.handlers import RotatingFileHandler 8 | 9 | 10 | CONFIGFILE = os.environ['CONFIGPATH'] 11 | # CONFIGPATH = CONFIGFILE.replace('config.yml', '') 12 | 13 | 14 | def upperescape(string): 15 | """Uppercase and Escape string. Used to help with YT-DL regex match. 16 | - ``string``: string to manipulate 17 | 18 | returns: 19 | ``string``: str new string 20 | """ 21 | # UPPERCASE as YTDL is case insensitive for ease. 22 | string = string.upper() 23 | # Remove quote characters as YTDL converts these. 24 | string = string.replace('’',"'") 25 | string = string.replace('“','"') 26 | string = string.replace('”','"') 27 | # Escape the characters 28 | string = re.escape(string) 29 | # Make it look for and as whole or ampersands 30 | string = string.replace('\\ AND\\ ','\\ (AND|&)\\ ') 31 | # Make punctuation optional for human error 32 | string = string.replace("'","([']?)") # optional apostrophe 33 | string = string.replace(",","([,]?)") # optional comma 34 | string = string.replace("!","([!]?)") # optional question mark 35 | string = string.replace("\\.","([\\.]?)") # optional period 36 | string = string.replace("\\?","([\\?]?)") # optional question mark 37 | string = string.replace(":","([:]?)") # optional colon 38 | string = re.sub("S\\\\", "([']?)"+"S\\\\", string) # optional belonging apostrophe (has to be last due to question mark) 39 | return string 40 | 41 | 42 | def checkconfig(): 43 | """Checks if config files exist in config path 44 | If no config available, will copy template to config folder and exit script 45 | 46 | returns: 47 | 48 | `cfg`: dict containing configuration values 49 | """ 50 | logger = logging.getLogger('sonarr_youtubedl') 51 | config_template = os.path.abspath(CONFIGFILE + '.template') 52 | config_template_exists = os.path.exists(os.path.abspath(config_template)) 53 | config_file = os.path.abspath(CONFIGFILE) 54 | config_file_exists = os.path.exists(os.path.abspath(config_file)) 55 | if not config_file_exists: 56 | logger.critical('Configuration file not found.') # print('Configuration file not found.') 57 | if not config_template_exists: 58 | os.system('cp /app/config.yml.template ' + config_template) 59 | logger.critical("Create a config.yml using config.yml.template as an example.") # sys.exit("Create a config.yml using config.yml.template as an example.") 60 | sys.exit() 61 | else: 62 | logger.info('Configuration Found. Loading file.') # print('Configuration Found. Loading file.') 63 | with open( 64 | config_file, 65 | "r" 66 | ) as ymlfile: 67 | cfg = yaml.load( 68 | ymlfile, 69 | Loader=yaml.BaseLoader 70 | ) 71 | return cfg 72 | 73 | 74 | def offsethandler(airdate, offset): 75 | """Adjusts an episodes airdate 76 | - ``airdate``: Airdate from sonarr # (datetime) 77 | - ``offset``: Offset from series config.yml # (dict) 78 | 79 | returns: 80 | ``airdate``: datetime updated original airdate 81 | """ 82 | weeks = 0 83 | days = 0 84 | hours = 0 85 | minutes = 0 86 | if 'weeks' in offset: 87 | weeks = int(offset['weeks']) 88 | if 'days' in offset: 89 | days = int(offset['days']) 90 | if 'hours' in offset: 91 | hours = int(offset['hours']) 92 | if 'minutes' in offset: 93 | minutes = int(offset['minutes']) 94 | airdate = airdate + datetime.timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes) 95 | return airdate 96 | 97 | 98 | class YoutubeDLLogger(object): 99 | 100 | def __init__(self): 101 | self.logger = logging.getLogger('sonarr_youtubedl') 102 | 103 | def info(self, msg: str) -> None: 104 | self.logger.info(msg) 105 | 106 | def debug(self, msg: str) -> None: 107 | self.logger.debug(msg) 108 | 109 | def warning(self, msg: str) -> None: 110 | self.logger.info(msg) 111 | 112 | def error(self, msg: str) -> None: 113 | self.logger.error(msg) 114 | 115 | 116 | def ytdl_hooks_debug(d): 117 | logger = logging.getLogger('sonarr_youtubedl') 118 | if d['status'] == 'finished': 119 | file_tuple = os.path.split(os.path.abspath(d['filename'])) 120 | logger.info(" Done downloading {}".format(file_tuple[1])) # print("Done downloading {}".format(file_tuple[1])) 121 | if d['status'] == 'downloading': 122 | progress = " {} - {} - {}".format(d['filename'], d['_percent_str'], d['_eta_str']) 123 | logger.debug(progress) 124 | 125 | 126 | def ytdl_hooks(d): 127 | logger = logging.getLogger('sonarr_youtubedl') 128 | if d['status'] == 'finished': 129 | file_tuple = os.path.split(os.path.abspath(d['filename'])) 130 | logger.info(" Downloaded - {}".format(file_tuple[1])) 131 | 132 | def setup_logging(lf_enabled=True, lc_enabled=True, debugging=False): 133 | log_level = logging.INFO 134 | log_level = logging.DEBUG if debugging == True else log_level 135 | logger = logging.getLogger('sonarr_youtubedl') 136 | logger.setLevel(log_level) 137 | log_format = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 138 | 139 | if lf_enabled: 140 | # setup logfile 141 | log_file = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'logs')) 142 | log_file = os.path.abspath(log_file + '/sonarr_youtubedl.log') 143 | loggerfile = RotatingFileHandler( 144 | log_file, 145 | maxBytes=5000000, 146 | backupCount=5 147 | ) 148 | loggerfile.setLevel(log_level) 149 | loggerfile.set_name('FileHandler') 150 | loggerfile.setFormatter(log_format) 151 | logger.addHandler(loggerfile) 152 | 153 | if lc_enabled: 154 | # setup console log 155 | loggerconsole = logging.StreamHandler() 156 | loggerconsole.setLevel(log_level) 157 | loggerconsole.set_name('StreamHandler') 158 | loggerconsole.setFormatter(log_format) 159 | logger.addHandler(loggerconsole) 160 | 161 | return logger 162 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.28.2 2 | yt-dlp==2023.03.04 3 | pyyaml==6.0 4 | schedule==1.1.0 5 | --------------------------------------------------------------------------------