diff --git a/README.md b/README.md index 2ccdec0..a2308bf 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,108 @@ +# kotlin-plexapi + +A Kotlin library for interacting with the Plex Media Server API. + +## Installation + +Add the dependency to your `build.gradle.kts`: + +```kotlin +repositories { + maven { + url = uri("https://maven.pkg.github.com/joeyberkovitz/kotlin-plexapi") + credentials { + username = "your-github-username" + password = "your-github-token" + } + } +} + +dependencies { + implementation("us.berkovitz:kotlin-plexapi:0.2.0") +} +``` + +## Features + +### Authentication +- `MyPlexAccount` - Authenticate with Plex account +- `MyPlexPinLogin` - PIN-based authentication flow + +### Playlists +- `PlexServer.playlists()` - Get all playlists +- `Playlist.items()` - Get tracks in a playlist + +### Library Browsing (v0.2.0) +- `PlexServer.librarySections()` - Get all library sections +- `PlexServer.musicSection()` - Find the music library section +- `PlexServer.artists(sectionId)` - Get all artists +- `PlexServer.albums(sectionId)` - Get all albums +- `PlexServer.tracks(sectionId)` - Get all tracks +- `PlexServer.artistAlbums(ratingKey)` - Get albums for an artist +- `PlexServer.albumTracks(ratingKey)` - Get tracks for an album + +## Usage + +### Connect to a Plex Server + +```kotlin +val account = MyPlexAccount(token) +val server = PlexServer(baseUrl, token) +``` + +### Browse Music Library + +```kotlin +// Find music library section +val musicSection = server.musicSection() + +// Get all artists +val artists = server.artists(musicSection.key) + +// Get albums for an artist +val albums = server.artistAlbums(artist.ratingKey) + +// Get tracks for an album +val tracks = server.albumTracks(album.ratingKey) +``` + +### Get Playlists + +```kotlin +val playlists = server.playlists(PlaylistType.AUDIO) +for (playlist in playlists) { + val tracks = playlist.items() +} +``` + +## Data Classes + +| Class | Description | +|-------|-------------| +| `PlexServer` | Represents a Plex Media Server connection | +| `LibrarySection` | A library section (Music, Movies, etc.) | +| `Artist` | A music artist | +| `Album` | A music album | +| `Track` | A music track | +| `Playlist` | A playlist | +| `Media` | Media file information | +| `Part` | File part information | + +## Changelog + +### v0.2.0 +- Added `LibrarySection` data class for library browsing +- Added `Artist` data class with `albums()` and `tracks()` methods +- Added `Album` data class with `tracks()` and `artist()` methods +- Added `Tag` data class for genres, countries, styles, moods +- Added `PlexServer` methods for music library browsing +- Fixed polymorphic serialization conflict between `Artist` and `Album` (both use `@SerialName("Directory")`) + +### v0.1.17 +- Added `originalTitle` field support + +## Credits + Thanks to: * https://github.com/jrudio/go-plex-client * https://github.com/pkkid/python-plexapi diff --git a/build.gradle.kts b/build.gradle.kts index b177b0c..842424a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,7 +14,7 @@ plugins { } group = "us.berkovitz" -version = "0.1.17" +version = "0.2.0" repositories { mavenCentral() diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/jvmMain/kotlin/us/berkovitz/plexapi/config/Http.kt b/src/jvmMain/kotlin/us/berkovitz/plexapi/config/Http.kt index 8cc8a94..3bfc324 100644 --- a/src/jvmMain/kotlin/us/berkovitz/plexapi/config/Http.kt +++ b/src/jvmMain/kotlin/us/berkovitz/plexapi/config/Http.kt @@ -19,6 +19,8 @@ import nl.adaptivity.xmlutil.serialization.DefaultXmlSerializationPolicy import nl.adaptivity.xmlutil.serialization.UnknownChildHandler import nl.adaptivity.xmlutil.serialization.XML import us.berkovitz.plexapi.logging.LoggingFactory +import us.berkovitz.plexapi.media.Album +import us.berkovitz.plexapi.media.Artist import us.berkovitz.plexapi.media.MediaItem import us.berkovitz.plexapi.media.Track import us.berkovitz.plexapi.myplex.handleErrors @@ -37,6 +39,10 @@ object Http { val serializerModule = SerializersModule { polymorphic(MediaItem::class) { subclass(Track::class) + // Note: Artist and Album both use @SerialName("Directory") + // so they can't be in the same polymorphic module. + // They're deserialized directly via MediaContainer + // or MediaContainer instead. } } diff --git a/src/jvmMain/kotlin/us/berkovitz/plexapi/media/Album.kt b/src/jvmMain/kotlin/us/berkovitz/plexapi/media/Album.kt new file mode 100644 index 0000000..b374219 --- /dev/null +++ b/src/jvmMain/kotlin/us/berkovitz/plexapi/media/Album.kt @@ -0,0 +1,79 @@ +package us.berkovitz.plexapi.media + +import io.ktor.client.call.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlDefault +import nl.adaptivity.xmlutil.serialization.XmlElement +import us.berkovitz.plexapi.config.Http + +/** + * Represents a music album in the Plex library. + */ +@Serializable +@SerialName("Directory") +data class Album( + val ratingKey: Long, + val key: String, + val parentRatingKey: Long? = null, + val guid: String? = null, + val parentGuid: String? = null, + val studio: String? = null, + val type: String = "album", + val title: String, + val titleSort: String? = null, + val parentKey: String? = null, + val parentTitle: String? = null, + val summary: String? = null, + val index: Int? = null, + val rating: Double? = null, + val year: Int? = null, + @XmlDefault("0") val leafCount: Int = 0, + @XmlDefault("0") val viewedLeafCount: Int = 0, + val thumb: String? = null, + val art: String? = null, + val parentThumb: String? = null, + val originallyAvailableAt: String? = null, + val addedAt: Long? = null, + val updatedAt: Long? = null, + val loudnessAnalysisVersion: Int? = null, + @XmlElement(true) @SerialName("Genre") val genres: List? = null, + @XmlElement(true) @SerialName("Style") val styles: List? = null, + @XmlElement(true) @SerialName("Mood") val moods: List? = null, + @XmlElement(true) @SerialName("Director") val directors: List? = null +) : MediaItem() { + + companion object { + /** + * Fetch an album by its rating key. + */ + suspend fun fromId(id: Long, server: PlexServer): Album? { + val url = server.urlFor("/library/metadata/$id") + val res: MediaContainer = Http.authenticatedGet(url, null, server.token).body() + if (res.elements.isEmpty()) return null + return res.elements[0].also { + it.setServer(server) + } + } + } + + /** + * Get all tracks in this album. + */ + suspend fun tracks(): List { + if (_server == null) return emptyList() + val url = _server!!.urlFor("/library/metadata/$ratingKey/children") + val res: MediaContainer = Http.authenticatedGet(url, null, _server!!.token).body() + return res.elements.map { + it.also { track -> track.setServer(_server!!) } + } + } + + /** + * Get the parent artist for this album. + */ + suspend fun artist(): Artist? { + if (_server == null || parentRatingKey == null) return null + return Artist.fromId(parentRatingKey, _server!!) + } +} diff --git a/src/jvmMain/kotlin/us/berkovitz/plexapi/media/Artist.kt b/src/jvmMain/kotlin/us/berkovitz/plexapi/media/Artist.kt new file mode 100644 index 0000000..c3b3c51 --- /dev/null +++ b/src/jvmMain/kotlin/us/berkovitz/plexapi/media/Artist.kt @@ -0,0 +1,70 @@ +package us.berkovitz.plexapi.media + +import io.ktor.client.call.* +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlElement +import us.berkovitz.plexapi.config.Http + +/** + * Represents a music artist in the Plex library. + */ +@Serializable +@SerialName("Directory") +data class Artist( + val ratingKey: Long, + val key: String, + val guid: String? = null, + val type: String = "artist", + val title: String, + val titleSort: String? = null, + val summary: String? = null, + val index: Int? = null, + val thumb: String? = null, + val art: String? = null, + val addedAt: Long? = null, + val updatedAt: Long? = null, + @XmlElement(true) @SerialName("Genre") val genres: List? = null, + @XmlElement(true) @SerialName("Country") val countries: List? = null, + @XmlElement(true) @SerialName("Style") val styles: List? = null, + @XmlElement(true) @SerialName("Mood") val moods: List? = null +) : MediaItem() { + + companion object { + /** + * Fetch an artist by its rating key. + */ + suspend fun fromId(id: Long, server: PlexServer): Artist? { + val url = server.urlFor("/library/metadata/$id") + val res: MediaContainer = Http.authenticatedGet(url, null, server.token).body() + if (res.elements.isEmpty()) return null + return res.elements[0].also { + it.setServer(server) + } + } + } + + /** + * Get all albums by this artist. + */ + suspend fun albums(): List { + if (_server == null) return emptyList() + val url = _server!!.urlFor("/library/metadata/$ratingKey/children") + val res: MediaContainer = Http.authenticatedGet(url, null, _server!!.token).body() + return res.elements.map { + it.also { album -> album.setServer(_server!!) } + } + } + + /** + * Get all tracks by this artist (across all albums). + */ + suspend fun tracks(): List { + if (_server == null) return emptyList() + val url = _server!!.urlFor("/library/metadata/$ratingKey/allLeaves") + val res: MediaContainer = Http.authenticatedGet(url, null, _server!!.token).body() + return res.elements.map { + it.also { track -> track.setServer(_server!!) } + } + } +} diff --git a/src/jvmMain/kotlin/us/berkovitz/plexapi/media/LibrarySection.kt b/src/jvmMain/kotlin/us/berkovitz/plexapi/media/LibrarySection.kt new file mode 100644 index 0000000..71ecf09 --- /dev/null +++ b/src/jvmMain/kotlin/us/berkovitz/plexapi/media/LibrarySection.kt @@ -0,0 +1,94 @@ +package us.berkovitz.plexapi.media + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlElement + +/** + * Represents a Plex library section (e.g., Music, Movies, TV Shows). + * + * For music libraries, `type` will be "artist". + */ +@Serializable +@SerialName("Directory") +data class LibrarySection( + val key: String, + val title: String, + val type: String, + val uuid: String? = null, + val agent: String? = null, + val scanner: String? = null, + val language: String? = null, + val art: String? = null, + val thumb: String? = null, + val composite: String? = null, + val createdAt: Long? = null, + val updatedAt: Long? = null, + val scannedAt: Long? = null, + @XmlElement(true) @SerialName("Location") val locations: List? = null +) : MediaItem() { + /** + * Get artists from this library section with pagination support. + * @param start Starting index for pagination (default 0) + * @param size Maximum number of items to return (default 100, use 0 for all) + */ + suspend fun artists(start: Int = 0, size: Int = 100): List { + if (_server == null) return emptyList() + return _server!!.artists(key, start, size) + } + + /** + * Get albums from this library section with pagination support. + * @param start Starting index for pagination (default 0) + * @param size Maximum number of items to return (default 100, use 0 for all) + */ + suspend fun albums(start: Int = 0, size: Int = 100): List { + if (_server == null) return emptyList() + return _server!!.albums(key, start, size) + } + + /** + * Get tracks from this library section with pagination support. + * @param start Starting index for pagination (default 0) + * @param size Maximum number of items to return (default 100, use 0 for all) + */ + suspend fun tracks(start: Int = 0, size: Int = 100): List { + if (_server == null) return emptyList() + return _server!!.tracks(key, start, size) + } + + /** + * Get recently added albums from this library section. + * @param limit Maximum number of items to return (default 50) + */ + suspend fun recentlyAddedAlbums(limit: Int = 50): List { + if (_server == null) return emptyList() + return _server!!.recentlyAddedAlbums(key, limit) + } + + /** + * Get recently played tracks from this library section. + * @param limit Maximum number of items to return (default 50) + */ + suspend fun recentlyPlayedTracks(limit: Int = 50): List { + if (_server == null) return emptyList() + return _server!!.recentlyPlayedTracks(key, limit) + } +} + +@Serializable +@SerialName("Location") +data class LibrarySectionLocation( + val id: Long, + val path: String +) + +/** + * Response container for library sections endpoint. + */ +@Serializable +@SerialName("MediaContainer") +data class LibrarySectionsResponse( + val size: Long, + @XmlElement(true) @SerialName("Directory") val sections: List +) diff --git a/src/jvmMain/kotlin/us/berkovitz/plexapi/media/PlexServer.kt b/src/jvmMain/kotlin/us/berkovitz/plexapi/media/PlexServer.kt index ee7eddf..d23289e 100644 --- a/src/jvmMain/kotlin/us/berkovitz/plexapi/media/PlexServer.kt +++ b/src/jvmMain/kotlin/us/berkovitz/plexapi/media/PlexServer.kt @@ -6,6 +6,15 @@ import io.ktor.http.* import us.berkovitz.plexapi.config.Http import us.berkovitz.plexapi.logging.LoggingFactory +/** + * Media type constants for Plex API queries. + */ +enum class MediaType(val value: String) { + ARTIST("8"), + ALBUM("9"), + TRACK("10") +} + class PlexServer( val baseUrl: String, val token: String // either token or accessToken @@ -14,6 +23,34 @@ class PlexServer( private val logger = LoggingFactory.loggerFor(PlexServer::class) } + /** + * Build pagination arguments for API requests. + * @param start Starting index (0-based) + * @param size Maximum number of items (0 = no limit) + * @return Map of pagination parameters + */ + private fun paginationArgs(start: Int, size: Int): MutableMap { + val args = mutableMapOf() + if (size > 0) { + args["X-Plex-Container-Start"] = start.toString() + args["X-Plex-Container-Size"] = size.toString() + } + return args + } + + /** + * Build arguments for a library section query with type and pagination. + * @param type The media type to filter by + * @param start Starting index for pagination + * @param size Maximum number of items + * @return Map of query parameters + */ + private fun sectionQueryArgs(type: MediaType, start: Int, size: Int): MutableMap { + val args = paginationArgs(start, size) + args["type"] = type.value + return args + } + suspend fun testConnection(): Boolean { try { val res = get("/", timeout = 5000) @@ -76,4 +113,124 @@ class PlexServer( return urlOut.buildString() } + /** + * Get all library sections from this server. + */ + suspend fun librarySections(): List { + val res: LibrarySectionsResponse = get("/library/sections").body() + return res.sections.map { it.also { section -> section.setServer(this) } } + } + + /** + * Find the first music library section. + * Music libraries have type="artist". + */ + suspend fun musicSection(): LibrarySection? { + return librarySections().find { it.type == "artist" } + } + + /** + * Get artists from a library section with pagination support. + * @param sectionId The library section key + * @param start Starting index for pagination (default 0) + * @param size Maximum number of items to return (default 100, use 0 for all) + */ + suspend fun artists(sectionId: String, start: Int = 0, size: Int = 100): List { + val args = sectionQueryArgs(MediaType.ARTIST, start, size) + val res: MediaContainer = get("/library/sections/$sectionId/all", args).body() + return res.elements.map { it.also { artist -> artist.setServer(this) } } + } + + /** + * Get albums from a library section with pagination support. + * @param sectionId The library section key + * @param start Starting index for pagination (default 0) + * @param size Maximum number of items to return (default 100, use 0 for all) + */ + suspend fun albums(sectionId: String, start: Int = 0, size: Int = 100): List { + val args = sectionQueryArgs(MediaType.ALBUM, start, size) + val res: MediaContainer = get("/library/sections/$sectionId/all", args).body() + return res.elements.map { it.also { album -> album.setServer(this) } } + } + + /** + * Get tracks from a library section with pagination support. + * @param sectionId The library section key + * @param start Starting index for pagination (default 0) + * @param size Maximum number of items to return (default 100, use 0 for all) + */ + suspend fun tracks(sectionId: String, start: Int = 0, size: Int = 100): List { + val args = sectionQueryArgs(MediaType.TRACK, start, size) + val res: MediaContainer = get("/library/sections/$sectionId/all", args).body() + return res.elements.map { it.also { track -> track.setServer(this) } } + } + + /** + * Get albums for a specific artist. + * @param artistRatingKey The artist's rating key + */ + suspend fun artistAlbums(artistRatingKey: Long): List { + val res: MediaContainer = get("/library/metadata/$artistRatingKey/children").body() + return res.elements.map { it.also { album -> album.setServer(this) } } + } + + /** + * Get tracks for a specific album. + * @param albumRatingKey The album's rating key + */ + suspend fun albumTracks(albumRatingKey: Long): List { + val res: MediaContainer = get("/library/metadata/$albumRatingKey/children").body() + return res.elements.map { it.also { track -> track.setServer(this) } } + } + + /** + * Get recently added tracks from the music library. + * @param sectionId The music library section key + * @param limit Maximum number of items to return (default 50) + */ + suspend fun recentlyAddedTracks(sectionId: String, limit: Int = 50): List { + val args = sectionQueryArgs(MediaType.TRACK, 0, limit) + args["sort"] = "addedAt:desc" + val res: MediaContainer = get("/library/sections/$sectionId/all", args).body() + return res.elements.map { it.also { track -> track.setServer(this) } } + } + + /** + * Get recently added albums from the music library. + * @param sectionId The music library section key + * @param limit Maximum number of items to return (default 50) + */ + suspend fun recentlyAddedAlbums(sectionId: String, limit: Int = 50): List { + val args = sectionQueryArgs(MediaType.ALBUM, 0, limit) + args["sort"] = "addedAt:desc" + val res: MediaContainer = get("/library/sections/$sectionId/all", args).body() + return res.elements.map { it.also { album -> album.setServer(this) } } + } + + /** + * Get recently played tracks from the music library. + * @param sectionId The music library section key + * @param limit Maximum number of items to return (default 50) + */ + suspend fun recentlyPlayedTracks(sectionId: String, limit: Int = 50): List { + val args = sectionQueryArgs(MediaType.TRACK, 0, limit) + args["sort"] = "lastViewedAt:desc" + args["viewCount%3E"] = "0" + val res: MediaContainer = get("/library/sections/$sectionId/all", args).body() + return res.elements.map { it.also { track -> track.setServer(this) } } + } + + /** + * Get on deck / continue listening items. + */ + suspend fun onDeck(): List { + try { + val res: MediaContainer = get("/library/onDeck").body() + return res.elements.map { it.also { track -> track.setServer(this) } } + } catch (e: Exception) { + logger.warn("Failed to get onDeck: ${e.message}") + return emptyList() + } + } + } diff --git a/src/jvmMain/kotlin/us/berkovitz/plexapi/media/Tag.kt b/src/jvmMain/kotlin/us/berkovitz/plexapi/media/Tag.kt new file mode 100644 index 0000000..3c22a72 --- /dev/null +++ b/src/jvmMain/kotlin/us/berkovitz/plexapi/media/Tag.kt @@ -0,0 +1,12 @@ +package us.berkovitz.plexapi.media + +import kotlinx.serialization.Serializable + +/** + * Generic tag element used for genres, countries, styles, moods, directors, etc. + */ +@Serializable +data class Tag( + val tag: String, + val id: Long? = null +)