Skip to content

Commit

Permalink
Allow plex friends to be fetched and populated via plex token (#43)
Browse files Browse the repository at this point in the history
  • Loading branch information
nylonee authored Nov 22, 2023
1 parent 5943b9a commit f94500c
Show file tree
Hide file tree
Showing 13 changed files with 300 additions and 14 deletions.
4 changes: 3 additions & 1 deletion src/main/scala/PlexTokenSync.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ object PlexTokenSync extends PlexUtils with SonarrUtils with RadarrUtils {
val result = for {
selfWatchlist <- getSelfWatchlist(config, client)
_ = logger.info(s"Found ${selfWatchlist.size} items on user's watchlist using the plex token")
othersWatchlist <- getOthersWatchlist(config, client)
_ = logger.info(s"Found ${othersWatchlist.size} items on other available watchlists using the plex token")
movies <- fetchMovies(client)(config.radarrApiKey, config.radarrBaseUrl, config.radarrBypassIgnored)
series <- fetchSeries(client)(config.sonarrApiKey, config.sonarrBaseUrl, config.sonarrBypassIgnored)
allIds = movies ++ series
_ <- missingIds(client)(config)(allIds, selfWatchlist)
_ <- missingIds(client)(config)(allIds, selfWatchlist ++ othersWatchlist)
} yield ()

result.leftMap {
Expand Down
12 changes: 10 additions & 2 deletions src/main/scala/http/HttpClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,16 @@ class HttpClient {
val client = EmberClientBuilder.default[IO].build

def httpRequest(method: Method, url: Uri, apiKey: Option[String] = None, payload: Option[Json] = None): IO[Either[Throwable, Json]] = {
val baseRequest = Request[IO](method = method, uri = url).withHeaders(Header.Raw(CIString("Accept"), "application/json"))
val requestWithApiKey = apiKey.fold(baseRequest)(key => baseRequest.withHeaders(Header.Raw(CIString("X-Api-Key"), key)))
val baseRequest = Request[IO](method = method, uri = url)
.withHeaders(
Header.Raw(CIString("Accept"), "application/json"),
Header.Raw(CIString("Content-Type"), "application/json")
)
val requestWithApiKey = apiKey.fold(baseRequest)(key => baseRequest.withHeaders(
Header.Raw(CIString("X-Api-Key"), key),
Header.Raw(CIString("X-Plex-Token"), key),
baseRequest.headers
))
val requestWithPayload = payload.fold(requestWithApiKey)(p => requestWithApiKey.withEntity(p))

client.use(_.expect[Json](requestWithPayload).attempt)
Expand Down
5 changes: 5 additions & 0 deletions src/main/scala/model/GraphQLQuery.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package model

import io.circe.Json

case class GraphQLQuery(query: String, variables: Option[Json] = None)
2 changes: 1 addition & 1 deletion src/main/scala/plex/MediaContainer.scala
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package plex

private[plex] case class MediaContainer(Metadata: List[TokenWatchlistItems])
private[plex] case class MediaContainer(Metadata: List[TokenWatchlistItem])
86 changes: 83 additions & 3 deletions src/main/scala/plex/PlexUtils.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import cats.effect.IO
import cats.implicits.toTraverseOps
import configuration.Configuration
import http.HttpClient
import model.Item
import model.{GraphQLQuery, Item}
import org.http4s.{Method, Uri}
import org.slf4j.LoggerFactory
import io.circe.generic.extras
import io.circe.generic.extras.auto._
import io.circe.syntax.EncoderOps

trait PlexUtils {

Expand Down Expand Up @@ -60,9 +61,88 @@ trait PlexUtils {
} yield result
}.getOrElse(EitherT.left(IO.pure(new Throwable("Plex tokens are not configured"))))

protected def getOthersWatchlist(config: Configuration, client: HttpClient): EitherT[IO, Throwable, Set[Item]] =
for {
friends <- getFriends(config, client)
watchlistItems <- friends.map(getWatchlistIdsForUser(config, client)).toList.sequence.map(_.flatten)
items <- watchlistItems.map(i => toItems(config, client, i)).sequence.map(_.toSet)
} yield items

protected def getFriends(config: Configuration, client: HttpClient): EitherT[IO, Throwable, Set[User]] =
config.plexToken.map { token =>
val url = Uri
.unsafeFromString("https://community.plex.tv/api")

val query = GraphQLQuery(
"""query GetAllFriends {
| allFriendsV2 {
| user {
| id
| username
| }
| }
| }""".stripMargin)

EitherT(client.httpRequest(Method.POST, url, Some(token), Some(query.asJson)).map {
case Left(err) =>
logger.warn(s"Unable to fetch friends from Plex: $err")
Left(err)
case Right(json) =>
json.as[Users] match {
case Right(v) => Right(v.data.allFriendsV2.map(_.user).toSet)
case Left(v) => Left(new Throwable(v))
}
})
}.getOrElse(EitherT.left(IO.pure(new Throwable("Plex tokens are not configured"))))

protected def getWatchlistIdsForUser(config: Configuration, client: HttpClient)(user: User): EitherT[IO, Throwable, Set[TokenWatchlistItem]] =
config.plexToken.map { token =>
val url = Uri.unsafeFromString("https://community.plex.tv/api")

val query = GraphQLQuery(
"""query GetWatchlistHub ($uuid: ID = "", $first: PaginationInt !, $after: String) {
user(id: $uuid) {
watchlist(first: $first, after: $after) {
nodes {
...itemFields
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
fragment itemFields on MetadataItem {
id
title
type
}""".stripMargin,
Some(
s"""{
| "first": 100,
| "uuid": "${user.id}"
|}""".stripMargin.asJson))

EitherT(client.httpRequest(Method.POST, url, Some(token), Some(query.asJson)).map {
case Left(err) =>
logger.warn(s"Unable to fetch friends watchlist from Plex: $err")
Left(err)
case Right(json) =>
json.as[TokenWatchlistFriend] match {
// TODO: Fetch the other pages if hasNextPage = true
case Right(v) => Right(v.data.user.watchlist.nodes.map(_.toTokenWatchlistItem).toSet)
case Left(v) => Left(new Throwable(v))
}
})
}.getOrElse(EitherT.left(IO.pure(new Throwable("Plex tokens are not configured"))))

// We don't have all the information available in TokenWatchlist
// so we need to make additional calls to Plex to get more information
private def toItems(config: Configuration, client: HttpClient)(plex: TokenWatchlist): EitherT[IO, Throwable, Set[Item]] = plex.MediaContainer.Metadata.map { i =>
private def toItems(config: Configuration, client: HttpClient)(plex: TokenWatchlist): EitherT[IO, Throwable, Set[Item]] =
plex.MediaContainer.Metadata.map(i => toItems(config, client, i)).sequence.map(_.toSet)

private def toItems(config: Configuration, client: HttpClient, i: TokenWatchlistItem): EitherT[IO, Throwable, Item] = {

val key = cleanKey(i.key)
val url = Uri
Expand All @@ -76,7 +156,7 @@ trait PlexUtils {
} yield guids

guids.map(ids => Item(i.title, ids, i.`type`))
}.sequence.map(_.toSet)
}

private def cleanKey(path: String): String =
if (path.endsWith("/children")) path.dropRight(9) else path
Expand Down
16 changes: 16 additions & 0 deletions src/main/scala/plex/TokenWatchlistFriend.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package plex

private[plex] case class TokenWatchlistFriend(data: WatchlistData)

private[plex] case class WatchlistData(user: WatchlistUserData)

private[plex] case class WatchlistUserData(watchlist: WatchlistNodes)

private[plex] case class WatchlistNodes(nodes: List[WatchlistNode], pageInfo: PageInfo)

private[plex] case class PageInfo(hasNextPage: Boolean, endCursor: String)

private[plex] case class WatchlistNode(id: String, title: String, `type`: String) {
def toTokenWatchlistItem: TokenWatchlistItem =
TokenWatchlistItem(title = title, guid = id, key = s"/library/metadata/$id", `type` = `type`.toLowerCase)
}
5 changes: 5 additions & 0 deletions src/main/scala/plex/TokenWatchlistItem.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package plex

private[plex] case class TokenWatchlistItem(title: String, guid: String, `type`: String, key: String, Guid: List[Guid] = List.empty)

private[plex] case class Guid(id: String)
5 changes: 0 additions & 5 deletions src/main/scala/plex/TokenWatchlistItems.scala

This file was deleted.

9 changes: 9 additions & 0 deletions src/main/scala/plex/User.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package plex

private[plex] case class Users(data: Data)

private[plex] case class Data(allFriendsV2: List[FriendV2])

private[plex] case class FriendV2(user: User)

private[plex] case class User(id: String, username: String)
18 changes: 18 additions & 0 deletions src/test/resources/plex-get-all-friends.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"data": {
"allFriendsV2": [
{
"user": {
"id": "ecdb6as0230e2115",
"username": "friend-1"
}
},
{
"user": {
"id": "a31281fd8s413643",
"username": "friend-2"
}
}
]
}
}
26 changes: 26 additions & 0 deletions src/test/resources/plex-get-watchlist-from-friend.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"data": {
"user": {
"watchlist": {
"nodes": [
{
"id": "5d77688b9ab54400214e789b",
"title": "The Twilight Saga: Breaking Dawn - Part 2",
"publicPagesURL": "https://watch.plex.tv/movie/the-twilight-saga-breaking-dawn-part-2",
"type": "MOVIE"
},
{
"id": "5d77688b594b2b001e68f2f0",
"title": "The Twilight Saga: Breaking Dawn - Part 1",
"publicPagesURL": "https://watch.plex.tv/movie/the-twilight-saga-breaking-dawn-part-1",
"type": "MOVIE"
}
],
"pageInfo": {
"hasNextPage": false,
"endCursor": "MTY1NjE4NDgyMzEzMA=="
}
}
}
}
}
74 changes: 74 additions & 0 deletions src/test/scala/PlexTokenSyncSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import cats.effect.unsafe.implicits.global
import configuration.Configuration
import http.HttpClient
import io.circe.parser._
import io.circe.generic.auto._
import io.circe.syntax.EncoderOps
import model.GraphQLQuery
import org.http4s.{Method, Uri}
import org.scalamock.scalatest.MockFactory
import org.scalatest.flatspec.AnyFlatSpec
Expand Down Expand Up @@ -65,6 +68,45 @@ class PlexTokenSyncSpec extends AnyFlatSpec with Matchers with MockFactory {
None,
None
).returning(IO.pure(parse(Source.fromResource("single-item-plex-metadata2.json").getLines().mkString("\n")))).once()
val query = GraphQLQuery(
"""query GetAllFriends {
| allFriendsV2 {
| user {
| id
| username
| }
| }
| }""".stripMargin)
(httpClient.httpRequest _).expects(
Method.POST,
Uri.unsafeFromString("https://community.plex.tv/api"),
Some("plex-token"),
Some(query.asJson)
).returning(IO.pure(parse(Source.fromResource("plex-get-all-friends.json").getLines().mkString("\n")))).once()
(httpClient.httpRequest _).expects(
Method.POST,
Uri.unsafeFromString("https://community.plex.tv/api"),
Some("plex-token"),
*
).returning(IO.pure(parse(Source.fromResource("plex-get-watchlist-from-friend.json").getLines().mkString("\n")))).twice()
(httpClient.httpRequest _).expects(
Method.GET,
Uri.unsafeFromString("https://discover.provider.plex.tv/library/metadata/5d77688b9ab54400214e789b?X-Plex-Token=plex-token"),
None,
None
).returning(IO.pure(parse(Source.fromResource("single-item-plex-metadata2.json").getLines().mkString("\n")))).once()
(httpClient.httpRequest _).expects(
Method.GET,
Uri.unsafeFromString("https://discover.provider.plex.tv/library/metadata/5d77688b594b2b001e68f2f0?X-Plex-Token=plex-token"),
None,
None
).returning(IO.pure(parse(Source.fromResource("single-item-plex-metadata2.json").getLines().mkString("\n")))).anyNumberOfTimes()
(httpClient.httpRequest _).expects(
Method.GET,
Uri.unsafeFromString("https://discover.provider.plex.tv/library/metadata/5d77688b9ab54400214e789b?X-Plex-Token=plex-token"),
None,
None
).returning(IO.pure(parse(Source.fromResource("single-item-plex-metadata2.json").getLines().mkString("\n")))).once()
httpClient
}

Expand All @@ -78,6 +120,26 @@ class PlexTokenSyncSpec extends AnyFlatSpec with Matchers with MockFactory {
| "searchForMovie" : true
| }
|}""".stripMargin
val movieToAdd2 =
"""{
| "title" : "The Twilight Saga: Breaking Dawn - Part 2",
| "tmdbId" : 1151534,
| "qualityProfileId" : 1,
| "rootFolderPath" : "/root/",
| "addOptions" : {
| "searchForMovie" : true
| }
|}""".stripMargin
val movieToAdd3 =
"""{
| "title" : "The Twilight Saga: Breaking Dawn - Part 1",
| "tmdbId" : 1151534,
| "qualityProfileId" : 1,
| "rootFolderPath" : "/root/",
| "addOptions" : {
| "searchForMovie" : true
| }
|}""".stripMargin
(httpClient.httpRequest _).expects(
Method.GET,
Uri.unsafeFromString("https://localhost:7878/api/v3/movie"),
Expand All @@ -96,6 +158,18 @@ class PlexTokenSyncSpec extends AnyFlatSpec with Matchers with MockFactory {
Some("radarr-api-key"),
parse(movieToAdd).toOption
).returning(IO.pure(parse("{}"))).once()
(httpClient.httpRequest _).expects(
Method.POST,
Uri.unsafeFromString("https://localhost:7878/api/v3/movie"),
Some("radarr-api-key"),
parse(movieToAdd2).toOption
).returning(IO.pure(parse("{}"))).once()
(httpClient.httpRequest _).expects(
Method.POST,
Uri.unsafeFromString("https://localhost:7878/api/v3/movie"),
Some("radarr-api-key"),
parse(movieToAdd3).toOption
).returning(IO.pure(parse("{}"))).once()
httpClient
}

Expand Down
Loading

0 comments on commit f94500c

Please sign in to comment.