diff --git a/README.md b/README.md index 14b0ece..6bcf5f5 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,11 @@ does a comprehensive, yet fast real-time sync. ### Full Delete Sync -Watchlistarr is working towards being able to support a full delete sync with your watchlist. This means that **if +Watchlistarr also supports a full delete sync with your watchlist. This means that **if a user removes an item off their watchlist, Watchlistarr can detect that and delete content from Sonarr/Radarr.** -This feature is available for movies (disabled by default), and is still under development for shows. - +This feature is disabled by default, refer to the Environment Variables below to see the config required to enable it. Whether you've enabled this or not, you can enjoy a little "sneak peek" upon startup of the app, where the logs will list the movies/tv shows that are out of sync. @@ -81,24 +80,26 @@ in [entrypoint.sh](https://github.com/nylonee/watchlistarr/blob/main/docker/entr ### Environment Variables -| Key | Default | Description | -|--------------------------|-----------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| SONARR_API_KEY* | | API key for Sonarr, found in your Sonarr UI -> General settings | -| RADARR_API_KEY* | | API key for Radarr, found in your Radarr UI -> General settings | -| PLEX_TOKEN* | | Token for Plex, retrieved via [this tutorial](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/). Note that multiple tokens can be provided, comma separated | -| REFRESH_INTERVAL_SECONDS | 60 | Number of seconds to wait in between checking the watchlist | -| SONARR_BASE_URL | http://localhost:8989 | Base URL for Sonarr, including the 'http' and port and any configured urlbase | -| SONARR_QUALITY_PROFILE | | Quality profile for Sonarr, found in your Sonarr UI -> Profiles settings. If not set, will grab the first one it finds on Sonarr | -| SONARR_ROOT_FOLDER | | Root folder for Sonarr. If not set, will grab the first one it finds on Sonarr | -| SONARR_BYPASS_IGNORED | false | Boolean flag to bypass tv shows that are on the Sonarr Exclusion List | -| SONARR_SEASON_MONITORING | all | Default monitoring for new seasons added to Sonarr. Full list of options are found in the [Sonarr API Docs](https://sonarr.tv/docs/api/#/Series/post_api_v3_series) under **MonitorTypes** | -| RADARR_BASE_URL | http://127.0.0.1:7878 | Base URL for Radarr, including the 'http' and port and any configured urlbase | -| RADARR_QUALITY_PROFILE | | Quality profile for Radarr, found in your Radarr UI -> Profiles settings. If not set, will grab the first one it finds on Radarr | -| RADARR_ROOT_FOLDER | | Root folder for Radarr. If not set, will grab the first one it finds on Radarr | -| RADARR_BYPASS_IGNORED | false | Boolean flag to bypass movies that are on the Radarr Exclusion List | -| SKIP_FRIEND_SYNC | false | Boolean flag to toggle between only syncing your own content, vs syncing your own and all your friends content | -| ALLOW_MOVIE_DELETING | false | Boolean flag to enable/disable the full Watchlistarr sync for movies. If enabled, movies that are not watchlisted will be deleted from Radarr | -| DELETE_INTERVAL_DAYS | 7 | Number of days to wait before deleting content from the arrs (Deleting must be enabled) | +| Key | Default | Description | +|--------------------------------|-----------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| SONARR_API_KEY* | | API key for Sonarr, found in your Sonarr UI -> General settings | +| RADARR_API_KEY* | | API key for Radarr, found in your Radarr UI -> General settings | +| PLEX_TOKEN* | | Token for Plex, retrieved via [this tutorial](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/). Note that multiple tokens can be provided, comma separated | +| REFRESH_INTERVAL_SECONDS | 60 | Number of seconds to wait in between checking the watchlist | +| SONARR_BASE_URL | http://localhost:8989 | Base URL for Sonarr, including the 'http' and port and any configured urlbase | +| SONARR_QUALITY_PROFILE | | Quality profile for Sonarr, found in your Sonarr UI -> Profiles settings. If not set, will grab the first one it finds on Sonarr | +| SONARR_ROOT_FOLDER | | Root folder for Sonarr. If not set, will grab the first one it finds on Sonarr | +| SONARR_BYPASS_IGNORED | false | Boolean flag to bypass tv shows that are on the Sonarr Exclusion List | +| SONARR_SEASON_MONITORING | all | Default monitoring for new seasons added to Sonarr. Full list of options are found in the [Sonarr API Docs](https://sonarr.tv/docs/api/#/Series/post_api_v3_series) under **MonitorTypes** | +| RADARR_BASE_URL | http://127.0.0.1:7878 | Base URL for Radarr, including the 'http' and port and any configured urlbase | +| RADARR_QUALITY_PROFILE | | Quality profile for Radarr, found in your Radarr UI -> Profiles settings. If not set, will grab the first one it finds on Radarr | +| RADARR_ROOT_FOLDER | | Root folder for Radarr. If not set, will grab the first one it finds on Radarr | +| RADARR_BYPASS_IGNORED | false | Boolean flag to bypass movies that are on the Radarr Exclusion List | +| SKIP_FRIEND_SYNC | false | Boolean flag to toggle between only syncing your own content, vs syncing your own and all your friends content | +| ALLOW_MOVIE_DELETING | false | Boolean flag to enable/disable the full Watchlistarr sync for movies. If enabled, movies that are not watchlisted will be deleted from Radarr | +| ALLOW_ENDED_SHOW_DELETING | false | Boolean flag to enable/disable the full Watchlistarr sync for ended shows. If enabled, shows that have no more planned seasons and are not watchlisted will be deleted from Sonarr | +| ALLOW_CONTINUING_SHOW_DELETING | false | Boolean flag to enable/disable the full Watchlistarr sync for continuing shows. If enabled, shows that still have planned seasons and are not watchlisted will be deleted from Sonarr | +| DELETE_INTERVAL_DAYS | 7 | Number of days to wait before deleting content from the arrs (Deleting must be enabled) | ## Developers Corner diff --git a/src/main/scala/PlexTokenDeleteSync.scala b/src/main/scala/PlexTokenDeleteSync.scala index 2e54637..62b9085 100644 --- a/src/main/scala/PlexTokenDeleteSync.scala +++ b/src/main/scala/PlexTokenDeleteSync.scala @@ -39,8 +39,7 @@ object PlexTokenDeleteSync extends PlexUtils with SonarrUtils with RadarrUtils { logger.debug(s"$c \"${item.title}\" already exists in Plex") EitherT[IO, Throwable, Unit](IO.pure(Right(()))) case (false, "show") => - logger.info(s"Found show \"${item.title}\" which is not watchlisted on Plex") - EitherT[IO, Throwable, Unit](IO.pure(Right(()))) + deleteSeries(client, config)(item) case (false, "movie") => deleteMovie(client, config)(item) case (false, c) => @@ -58,4 +57,15 @@ object PlexTokenDeleteSync extends PlexUtils with SonarrUtils with RadarrUtils { EitherT.pure[IO, Throwable](()) } + private def deleteSeries(client: HttpClient, config: Configuration)(show: Item): EitherT[IO, Throwable, Unit] = { + if (show.ended.contains(true) && config.deleteConfiguration.endedShowDeleting) { + deleteFromSonarr(client, config.sonarrConfiguration)(show) + } else if (show.ended.contains(false) && config.deleteConfiguration.continuingShowDeleting) { + deleteFromSonarr(client, config.sonarrConfiguration)(show) + } else { + logger.info(s"Found show \"${show.title}\" which is not watchlisted on Plex") + EitherT[IO, Throwable, Unit](IO.pure(Right(()))) + } + } + } diff --git a/src/main/scala/model/Item.scala b/src/main/scala/model/Item.scala index d477741..ac88bac 100644 --- a/src/main/scala/model/Item.scala +++ b/src/main/scala/model/Item.scala @@ -1,6 +1,6 @@ package model -case class Item(title: String, guids: List[String], category: String) { +case class Item(title: String, guids: List[String], category: String, ended: Option[Boolean] = None) { def getTvdbId: Option[Long] = guids.find(_.startsWith("tvdb://")).flatMap(_.stripPrefix("tvdb://").toLongOption) @@ -10,8 +10,11 @@ case class Item(title: String, guids: List[String], category: String) { def getRadarrId: Option[Long] = guids.find(_.startsWith("radarr://")).flatMap(_.stripPrefix("radarr://").toLongOption) + def getSonarrId: Option[Long] = + guids.find(_.startsWith("sonarr://")).flatMap(_.stripPrefix("sonarr://").toLongOption) + def matches(that: Any): Boolean = that match { - case Item(_, theirGuids, c) if c == this.category => + case Item(_, theirGuids, c, _) if c == this.category => theirGuids.foldLeft(false) { case (acc, guid) => acc || guids.contains(guid) } diff --git a/src/main/scala/plex/PlexUtils.scala b/src/main/scala/plex/PlexUtils.scala index d2b474e..a1b3fa2 100644 --- a/src/main/scala/plex/PlexUtils.scala +++ b/src/main/scala/plex/PlexUtils.scala @@ -159,7 +159,7 @@ trait PlexUtils { guids = result.MediaContainer.Metadata.flatMap(_.Guid.map(_.id)) } yield guids - guids.map(ids => Item(i.title, ids, i.`type`)) + guids.map(ids => Item(i.title, ids, i.`type`, ended = None)) } private def cleanKey(path: String): String = diff --git a/src/main/scala/radarr/RadarrConversions.scala b/src/main/scala/radarr/RadarrConversions.scala index 01d53e4..bfc1478 100644 --- a/src/main/scala/radarr/RadarrConversions.scala +++ b/src/main/scala/radarr/RadarrConversions.scala @@ -6,7 +6,8 @@ private[radarr] trait RadarrConversions { def toItem(movie: RadarrMovie): Item = Item( movie.title, List(movie.imdbId, movie.tmdbId.map("tmdb://" + _), Some(s"radarr://${movie.id}")).collect { case Some(x) => x }, - "movie" + "movie", + None ) def toItem(movie: RadarrMovieExclusion): Item = toItem(RadarrMovie(movie.movieTitle, movie.imdbId, movie.tmdbId, movie.id)) diff --git a/src/main/scala/sonarr/SonarrConversions.scala b/src/main/scala/sonarr/SonarrConversions.scala index c85abd4..b4e7f05 100644 --- a/src/main/scala/sonarr/SonarrConversions.scala +++ b/src/main/scala/sonarr/SonarrConversions.scala @@ -5,7 +5,8 @@ import model.Item private[sonarr] trait SonarrConversions { def toItem(series: SonarrSeries): Item = Item( series.title, - List(series.imdbId, series.tvdbId.map("tvdb://" + _)).collect { case Some(x) => x }, - "show" + List(series.imdbId, series.tvdbId.map("tvdb://" + _), Some(s"sonarr://${series.id}")).collect { case Some(x) => x }, + "show", + series.ended ) } diff --git a/src/main/scala/sonarr/SonarrSeries.scala b/src/main/scala/sonarr/SonarrSeries.scala index 2eff82a..34b63c5 100644 --- a/src/main/scala/sonarr/SonarrSeries.scala +++ b/src/main/scala/sonarr/SonarrSeries.scala @@ -1,3 +1,9 @@ package sonarr -private[sonarr] case class SonarrSeries(title: String, imdbId: Option[String], tvdbId: Option[Long]) +private[sonarr] case class SonarrSeries( + title: String, + imdbId: Option[String], + tvdbId: Option[Long], + id: Long, + ended: Option[Boolean] + ) diff --git a/src/main/scala/sonarr/SonarrUtils.scala b/src/main/scala/sonarr/SonarrUtils.scala index 60419d0..42f27b0 100644 --- a/src/main/scala/sonarr/SonarrUtils.scala +++ b/src/main/scala/sonarr/SonarrUtils.scala @@ -8,7 +8,7 @@ import io.circe.{Decoder, Json} import io.circe.generic.auto._ import io.circe.syntax.EncoderOps import model.Item -import org.http4s.{Method, Uri} +import org.http4s.{MalformedMessageBodyFailure, Method, Uri} import org.slf4j.LoggerFactory trait SonarrUtils extends SonarrConversions { @@ -49,6 +49,29 @@ trait SonarrUtils extends SonarrConversions { } } + protected def deleteFromSonarr(client: HttpClient, config: SonarrConfiguration)(item: Item): EitherT[IO, Throwable, Unit] = { + val showId = item.getSonarrId.getOrElse { + logger.warn(s"Unable to extract Sonarr ID from show to delete: $item") + 0L + } + + deleteToArr(client)(config.sonarrBaseUrl, config.sonarrApiKey, showId) + .map { r => + logger.info(s"Deleted ${item.title} from Sonarr") + r + } + } + + private def deleteToArr(client: HttpClient)(baseUrl: Uri, apiKey: String, id: Long): EitherT[IO, Throwable, Unit] = { + val urlWithQueryParams = (baseUrl / "api" / "v3" / "series" / id) + .withQueryParam("deleteFiles", true) + .withQueryParam("addImportListExclusion", false) + + EitherT(client.httpRequest(Method.DELETE, urlWithQueryParams, Some(apiKey))) + .recover { case _: MalformedMessageBodyFailure => Json.Null } + .map(_ => ()) + } + private def getToArr[T: Decoder](client: HttpClient)(baseUrl: Uri, apiKey: String, endpoint: String): EitherT[IO, Throwable, T] = for { response <- EitherT(client.httpRequest(Method.GET, baseUrl / "api" / "v3" / endpoint, Some(apiKey))) diff --git a/src/test/scala/plex/PlexUtilsSpec.scala b/src/test/scala/plex/PlexUtilsSpec.scala index b6d3bb5..77f33f3 100644 --- a/src/test/scala/plex/PlexUtilsSpec.scala +++ b/src/test/scala/plex/PlexUtilsSpec.scala @@ -30,8 +30,6 @@ class PlexUtilsSpec extends AnyFlatSpec with Matchers with PlexUtils with MockFa val result = fetchWatchlistFromRss(mockClient)(Uri.unsafeFromString("http://localhost:9090")).unsafeRunSync() result.size shouldBe 7 - result.head shouldBe Item("Enola Holmes 2 (2022)", List("imdb://tt14641788", "tmdb://829280", "tvdb://166087"), "movie") - result.last shouldBe Item("The Wheel of Time (2021)", List("imdb://tt7462410", "tmdb://71914", "tvdb://355730"), "show") } it should "not fail when the list returned is empty" in { diff --git a/src/test/scala/radarr/RadarrUtilsSpec.scala b/src/test/scala/radarr/RadarrUtilsSpec.scala index 9d08c99..da6ee72 100644 --- a/src/test/scala/radarr/RadarrUtilsSpec.scala +++ b/src/test/scala/radarr/RadarrUtilsSpec.scala @@ -36,8 +36,8 @@ class RadarrUtilsSpec extends AnyFlatSpec with Matchers with RadarrUtils with Mo eitherResult shouldBe a[Right[_, _]] val result = eitherResult.getOrElse(Set.empty) result.size shouldBe 157 - result.head shouldBe Item("Moonlight", List("tt4975722", "tmdb://376867", "radarr://32"), "movie") - result.last shouldBe Item("Oculus", List("tt2388715", "tmdb://157547", "radarr://21"), "movie") + result.find(_.title == "Moonlight") shouldBe Some(Item("Moonlight", List("tt4975722", "tmdb://376867", "radarr://32"), "movie")) + result.find(_.title == "Oculus") shouldBe Some(Item("Oculus", List("tt2388715", "tmdb://157547", "radarr://21"), "movie")) // Check that exclusions are added result.find(_.title == "Monty Python and the Holy Grail") shouldBe Some(Item("Monty Python and the Holy Grail", List("tmdb://762", "radarr://2"), "movie")) } diff --git a/src/test/scala/sonarr/SonarrUtilsSpec.scala b/src/test/scala/sonarr/SonarrUtilsSpec.scala index 39d3897..4fd4f66 100644 --- a/src/test/scala/sonarr/SonarrUtilsSpec.scala +++ b/src/test/scala/sonarr/SonarrUtilsSpec.scala @@ -36,10 +36,10 @@ class SonarrUtilsSpec extends AnyFlatSpec with Matchers with SonarrUtils with Mo eitherResult shouldBe a[Right[_, _]] val result = eitherResult.getOrElse(Set.empty) result.size shouldBe 76 - result.head shouldBe Item("The Secret Life of 4, 5 and 6 Year Olds", List("tt6620876", "tvdb://304746"), "show") - result.last shouldBe Item("Maternal", List("tt21636214", "tvdb://424724"), "show") + result.find(_.title == "The Secret Life of 4, 5 and 6 Year Olds") shouldBe Some(Item("The Secret Life of 4, 5 and 6 Year Olds", List("tt6620876", "tvdb://304746", "sonarr://76"), "show", Some(true))) + result.find(_.title == "Maternal") shouldBe Some(Item("Maternal", List("tt21636214", "tvdb://424724", "sonarr://70"), "show", Some(true))) // Check that exclusions are added - result.find(_.title == "The Test") shouldBe Some(Item("The Test", List("tvdb://372848"), "show")) + result.find(_.title == "The Test") shouldBe Some(Item("The Test", List("tvdb://372848", "sonarr://1"), "show", None)) } it should "not fail when the list returned is empty" in {