Skip to content
105 changes: 105 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ plugins {
}

group = "us.berkovitz"
version = "0.1.17"
version = "0.2.0"

repositories {
mavenCentral()
Expand Down
Empty file modified gradlew
100644 → 100755
Empty file.
6 changes: 6 additions & 0 deletions src/jvmMain/kotlin/us/berkovitz/plexapi/config/Http.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Artist>
// or MediaContainer<Album> instead.
Comment on lines +42 to +45

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not too certain about this. kotlinx serialization has pretty advanced polymorphic support. I think it can work

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran into issues with the XML serialization discriminator when Artist and Album were in the polymorphic module. Happy to revisit if you have a working pattern - the current approach works but I agree it's not ideal.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK; Let me take a look

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH - the comment is just what's throwing me off. It's just irrelevant. The only reason why I had the polymorphism here is to support Playlist::items() which returns Array<MediaItem> where in theory the members of the playlist aren't strictly Tracks but rather could be other items if the API allowed it.

Nothing in this PR uses generics in responses, so this isn't needed

}
}

Expand Down
79 changes: 79 additions & 0 deletions src/jvmMain/kotlin/us/berkovitz/plexapi/media/Album.kt
Original file line number Diff line number Diff line change
@@ -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<Tag>? = null,
@XmlElement(true) @SerialName("Style") val styles: List<Tag>? = null,
@XmlElement(true) @SerialName("Mood") val moods: List<Tag>? = null,
@XmlElement(true) @SerialName("Director") val directors: List<Tag>? = 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<Album> = 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<Track> {
if (_server == null) return emptyList()
val url = _server!!.urlFor("/library/metadata/$ratingKey/children")
val res: MediaContainer<Track> = 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!!)
}
}
70 changes: 70 additions & 0 deletions src/jvmMain/kotlin/us/berkovitz/plexapi/media/Artist.kt
Original file line number Diff line number Diff line change
@@ -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<Tag>? = null,
@XmlElement(true) @SerialName("Country") val countries: List<Tag>? = null,
@XmlElement(true) @SerialName("Style") val styles: List<Tag>? = null,
@XmlElement(true) @SerialName("Mood") val moods: List<Tag>? = 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<Artist> = 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<Album> {
if (_server == null) return emptyList()
val url = _server!!.urlFor("/library/metadata/$ratingKey/children")
val res: MediaContainer<Album> = 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<Track> {
if (_server == null) return emptyList()
val url = _server!!.urlFor("/library/metadata/$ratingKey/allLeaves")
val res: MediaContainer<Track> = Http.authenticatedGet(url, null, _server!!.token).body()
return res.elements.map {
it.also { track -> track.setServer(_server!!) }
}
}
}
94 changes: 94 additions & 0 deletions src/jvmMain/kotlin/us/berkovitz/plexapi/media/LibrarySection.kt
Original file line number Diff line number Diff line change
@@ -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,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment on everything, I don't get why there's a ? = null if the default value for a nullable field is just null anyway

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The = null defaults are required for kotlinx.serialization to handle optional fields. Without explicit defaults, missing fields in API responses cause deserialization errors. This is a kotlinx.serialization requirement, not just Kotlin syntax.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a unit test to illustrate that it works without this. See ac59cf6

I intentionally left fields unpopulated in the Playlist XML, such as summary and titleSort. The test still passes

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<LibrarySectionLocation>? = 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<Artist> {
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<Album> {
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<Track> {
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<Album> {
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<Track> {
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<LibrarySection>
)
Comment on lines +89 to +94

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So LibrarySectionsResponse is just MediaContainer<LibrarySection>?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LibrarySectionsResponse is needed because the /library/sections endpoint returns <Directory> elements directly under MediaContainer, whereas MediaContainer expects elements in a generic list. The XML structure differs from other endpoints, requiring this custom wrapper.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's just how MediaContainer always works though?

See the newly added Playlist unit test. You get a <MediaContainer> with <Playlist> elements directly under the MediaContainer

Ref: ac59cf6

Loading