Skip to content

Commit

Permalink
Delete series from Sonarr (#77)
Browse files Browse the repository at this point in the history
  • Loading branch information
nylonee authored Jan 5, 2024
1 parent 0d645fd commit 32101ae
Show file tree
Hide file tree
Showing 11 changed files with 81 additions and 38 deletions.
43 changes: 22 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
14 changes: 12 additions & 2 deletions src/main/scala/PlexTokenDeleteSync.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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(())))
}
}

}
7 changes: 5 additions & 2 deletions src/main/scala/model/Item.scala
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/scala/plex/PlexUtils.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
3 changes: 2 additions & 1 deletion src/main/scala/radarr/RadarrConversions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
5 changes: 3 additions & 2 deletions src/main/scala/sonarr/SonarrConversions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
8 changes: 7 additions & 1 deletion src/main/scala/sonarr/SonarrSeries.scala
Original file line number Diff line number Diff line change
@@ -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]
)
25 changes: 24 additions & 1 deletion src/main/scala/sonarr/SonarrUtils.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)))
Expand Down
2 changes: 0 additions & 2 deletions src/test/scala/plex/PlexUtilsSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/test/scala/radarr/RadarrUtilsSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Expand Down
6 changes: 3 additions & 3 deletions src/test/scala/sonarr/SonarrUtilsSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

0 comments on commit 32101ae

Please sign in to comment.