Refactored Sonarr and Radarr hook. It may be a breaking change so users should review webhook parameters following information in Bazarr's settings.

This commit is contained in:
Aden Northcote
2025-05-22 19:52:29 +10:00
committed by GitHub
parent f2cf1c066c
commit a3102e8a19
4 changed files with 198 additions and 47 deletions

View File

@@ -1,8 +1,10 @@
# coding=utf-8
import logging
from flask_restx import Resource, Namespace, reqparse
from flask_restx import Resource, Namespace, fields
from app.database import TableMovies, database, select
from radarr.sync.movies import update_one_movie
from subtitles.mass_download import movies_download_subtitles
from subtitles.indexer.movies import store_subtitles_movie
from utilities.path_mappings import path_mappings
@@ -10,31 +12,99 @@ from utilities.path_mappings import path_mappings
from ..utils import authenticate
api_ns_webhooks_radarr = Namespace('Webhooks Radarr', description='Webhooks to trigger subtitles search based on '
'Radarr movie file ID')
api_ns_webhooks_radarr = Namespace(
"Webhooks Radarr",
description="Webhooks to trigger subtitles search based on Radarr webhooks",
)
@api_ns_webhooks_radarr.route('webhooks/radarr')
@api_ns_webhooks_radarr.route("webhooks/radarr")
class WebHooksRadarr(Resource):
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('radarr_moviefile_id', type=int, required=True, help='Movie file ID')
movie_model = api_ns_webhooks_radarr.model(
"RadarrMovie",
{
"id": fields.Integer(required=True, description="Movie ID"),
},
strict=False,
)
movie_file_model = api_ns_webhooks_radarr.model(
"RadarrMovieFile",
{
"id": fields.Integer(required=True, description="Movie file ID"),
},
strict=False,
)
radarr_webhook_model = api_ns_webhooks_radarr.model(
"RadarrWebhook",
{
"eventType": fields.String(
required=True,
description="Type of Radarr event (e.g. MovieAdded, Test, etc)",
),
"movieFile": fields.Nested(
movie_file_model,
required=False,
description="Radarr movie file payload. Required for anything other than test hooks",
),
"movie": fields.Nested(
movie_model,
required=False,
description="Radarr movie payload. Can be used to sync movies from Radarr if not found in Bazarr",
),
},
strict=False,
)
@authenticate
@api_ns_webhooks_radarr.doc(parser=post_request_parser)
@api_ns_webhooks_radarr.response(200, 'Success')
@api_ns_webhooks_radarr.response(401, 'Not Authenticated')
@api_ns_webhooks_radarr.expect(radarr_webhook_model, validate=True)
@api_ns_webhooks_radarr.response(200, "Success")
@api_ns_webhooks_radarr.response(401, "Not Authenticated")
def post(self):
"""Search for missing subtitles for a specific movie file id"""
args = self.post_request_parser.parse_args()
movie_file_id = args.get('radarr_moviefile_id')
"""Search for missing subtitles based on Radarr webhooks"""
args = api_ns_webhooks_radarr.payload
event_type = args.get("eventType")
radarrMovieId = database.execute(
logging.debug(f"Received Radarr webhook event: {event_type}")
if event_type == "Test":
message = "Received test hook, skipping database search."
logging.debug(message)
return message, 200
movie_file_id = args.get("movieFile", {}).get("id")
if not movie_file_id:
message = "No movie file ID found in the webhook request. Nothing to do."
logging.debug(message)
# Radarr reports the webhook as 'unhealthy' and requires
# user interaction if we return anything except 200s.
return message, 200
# This webhook is often faster than the database update,
# so we update the movie first if we can.
radarr_id = args.get("movie", {}).get("id")
q = (
select(TableMovies.radarrId, TableMovies.path)
.where(TableMovies.movie_file_id == movie_file_id)) \
.where(TableMovies.movie_file_id == movie_file_id)
.first()
)
if radarrMovieId:
store_subtitles_movie(radarrMovieId.path, path_mappings.path_replace_movie(radarrMovieId.path))
movies_download_subtitles(no=radarrMovieId.radarrId)
movie = database.execute(q)
if not movie and radarr_id:
logging.debug(
f"No movie matching file ID {movie_file_id} found in the database. Attempting to sync from Radarr."
)
update_one_movie(radarr_id, "updated")
movie = database.execute(q)
if not movie:
message = f"No movie matching file ID {movie_file_id} found in the database. Nothing to do."
logging.debug(message)
return message, 200
return '', 200
store_subtitles_movie(movie.path, path_mappings.path_replace_movie(movie.path))
movies_download_subtitles(no=movie.radarrId)
return "Finished processing subtitles.", 200

View File

@@ -1,42 +1,121 @@
# coding=utf-8
import logging
from flask_restx import Resource, Namespace, reqparse
from flask_restx import Resource, Namespace, fields
from app.database import TableEpisodes, TableShows, database, select
from sonarr.sync.episodes import sync_one_episode
from subtitles.mass_download import episode_download_subtitles
from subtitles.indexer.series import store_subtitles
from utilities.path_mappings import path_mappings
from ..utils import authenticate
api_ns_webhooks_sonarr = Namespace('Webhooks Sonarr', description='Webhooks to trigger subtitles search based on '
'Sonarr episode file ID')
api_ns_webhooks_sonarr = Namespace(
"Webhooks Sonarr",
description="Webhooks to trigger subtitles search based on Sonarr webhooks",
)
@api_ns_webhooks_sonarr.route('webhooks/sonarr')
@api_ns_webhooks_sonarr.route("webhooks/sonarr")
class WebHooksSonarr(Resource):
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('sonarr_episodefile_id', type=int, required=True, help='Episode file ID')
episode_model = api_ns_webhooks_sonarr.model(
"SonarrEpisode",
{
"id": fields.Integer(required=True, description="Episode ID"),
},
strict=False,
)
episode_file_model = api_ns_webhooks_sonarr.model(
"SonarrEpisodeFile",
{
"id": fields.Integer(required=True, description="Episode file ID"),
},
strict=False,
)
sonarr_webhook_model = api_ns_webhooks_sonarr.model(
"SonarrWebhook",
{
"episodes": fields.List(
fields.Nested(episode_model),
required=False,
description="List of episodes. Can be used to sync episodes from Sonarr if not found in Bazarr.",
),
"episodeFiles": fields.List(
fields.Nested(episode_file_model),
required=False,
description="List of episode files; required for anything other than test hooks",
),
"eventType": fields.String(
required=True,
description="Type of Sonarr event (e.g. Test, Download, etc.)",
),
},
strict=False,
)
@authenticate
@api_ns_webhooks_sonarr.doc(parser=post_request_parser)
@api_ns_webhooks_sonarr.response(200, 'Success')
@api_ns_webhooks_sonarr.response(401, 'Not Authenticated')
@api_ns_webhooks_sonarr.expect(sonarr_webhook_model, validate=True)
@api_ns_webhooks_sonarr.response(200, "Success")
@api_ns_webhooks_sonarr.response(401, "Not Authenticated")
def post(self):
"""Search for missing subtitles for a specific episode file id"""
args = self.post_request_parser.parse_args()
episode_file_id = args.get('sonarr_episodefile_id')
"""Search for missing subtitles based on Sonarr webhooks"""
args = api_ns_webhooks_sonarr.payload
event_type = args.get("eventType")
sonarrEpisodeId = database.execute(
select(TableEpisodes.sonarrEpisodeId, TableEpisodes.path)
.select_from(TableEpisodes)
.join(TableShows)
.where(TableEpisodes.episode_file_id == episode_file_id)) \
.first()
logging.debug(f"Received Sonarr webhook event: {event_type}")
if sonarrEpisodeId:
store_subtitles(sonarrEpisodeId.path, path_mappings.path_replace(sonarrEpisodeId.path))
episode_download_subtitles(no=sonarrEpisodeId.sonarrEpisodeId, send_progress=True)
if event_type == "Test":
message = "Received test hook, skipping database search."
logging.debug(message)
return message, 200
return '', 200
# Sonarr hooks only differentiate a download starting vs. ending by
# the inclusion of episodeFiles in the payload.
sonarr_episode_file_ids = [e.get("id") for e in args.get("episodeFiles", [])]
if not sonarr_episode_file_ids:
message = "No episode file IDs found in the webhook request. Nothing to do."
logging.debug(message)
# Sonarr reports the webhook as 'unhealthy' and requires
# user interaction if we return anything except 200s.
return message, 200
sonarr_episode_ids = [e.get("id") for e in args.get("episodes", [])]
if len(sonarr_episode_ids) != len(sonarr_episode_file_ids):
logging.debug(
"Episode IDs and episode file IDs are different lengths, ignoring episode IDs."
)
sonarr_episode_ids = []
for i, efid in enumerate(sonarr_episode_file_ids):
q = (
select(TableEpisodes.sonarrEpisodeId, TableEpisodes.path)
.select_from(TableEpisodes)
.join(TableShows)
.where(TableEpisodes.episode_file_id == efid)
)
episode = database.execute(q).first()
if not episode and sonarr_episode_ids:
logging.debug(
"No episode found for episode file ID %s, attempting to sync from Sonarr.",
efid,
)
sync_one_episode(sonarr_episode_ids[i])
episode = database.execute(q).first()
if not episode:
logging.debug(
"No episode found for episode file ID %s, skipping.", efid
)
continue
store_subtitles(episode.path, path_mappings.path_replace(episode.path))
episode_download_subtitles(no=episode.sonarrEpisodeId, send_progress=True)
return "Finished processing subtitles.", 200

View File

@@ -84,9 +84,10 @@ const SettingsRadarrView: FunctionComponent = () => {
<Message>
Search can be triggered using this command
<Code>
curl -d "radarr_moviefile_id=$radarr_moviefile_id" -H "x-api-key:
###############################" -X POST
http://localhost:6767/api/webhooks/radarr
{`curl -H "Content-Type: application/json" -H "X-API-KEY: ###############################" -X POST
-d '{ "eventType": "Download", "movieFile": [ { "id": "$radarr_moviefile_id" } ] }'
http://localhost:6767/api/webhooks/radarr
`}
</Code>
</Message>
</Section>

View File

@@ -93,11 +93,12 @@ const SettingsSonarrView: FunctionComponent = () => {
as soon as episodes are imported.
</Message>
<Message>
Search can be triggered using this command
Search can be triggered using this command:
<Code>
curl -d "sonarr_episodefile_id=$sonarr_episodefile_id" -H
"x-api-key: ###############################" -X POST
http://localhost:6767/api/webhooks/sonarr
{`curl -H "Content-Type: application/json" -H "X-API-KEY: ###############################" -X POST
-d '{ "eventType": "Download", "episodeFiles": [ { "id": "$sonarr_episodefile_id" } ] }'
http://localhost:6767/api/webhooks/sonarr
`}
</Code>
</Message>
<Check