hitomi 0.8.1

A CLI application that builds and updates playlists on a Plex server using json-based profiles.
Documentation
use std::collections::HashMap;

use anyhow::{anyhow, Result};
use derive_builder::Builder;
use itertools;
use itertools::Itertools;
use log::{error, info};
use reqwest::Url;
use serde::Deserialize;
use simplelog::debug;

use crate::config::Config;
use crate::http_client::HttpClient;
use crate::plex::models::artists::Artist;
use crate::plex::models::collections::{Collection, SubType};
use crate::plex::models::new_playlist::NewPlaylist;
use crate::plex::models::playlists::Playlist;
use crate::plex::models::sections::Section;
use crate::plex::models::tracks::Track;
use crate::plex::models::{MediaContainerWrapper, PlexResponse, SectionResponse};
use crate::profiles::profile::Profile;
use crate::types::plex::plex_id::PlexId;
use crate::types::plex::plex_token::PlexToken;

pub mod models;

/// Plex API wrapper
///
/// Dead code is allowed for this specific struct due to [`Builder`]
/// using both the `plex_token` and `plex_url` fields.
#[allow(dead_code)]
#[derive(Builder, Clone, Debug)]
pub struct PlexClient {
    client: HttpClient,
    plex_token: PlexToken,
    plex_url: Url,
    #[builder(default)]
    machine_identifier: String,
    #[builder(default)]
    primary_section_id: u32,
    #[builder(default)]
    playlists: Vec<Playlist>,
    #[builder(default)]
    collections: Vec<Collection>,
    #[builder(default)]
    sections: Vec<Section>,
}

impl PlexClient {
    pub async fn initialize(config: &Config) -> Result<Self> {
        debug!("Initializing plex...");

        let plex_url = config.get_plex_url()?;
        let plex_token = config.get_plex_token()?;

        let client = HttpClient::new(plex_url.as_str(), plex_token.as_str())?;

        let mut plex = PlexClientBuilder::default()
            .client(client)
            .plex_token(plex_token)
            .plex_url(plex_url)
            .primary_section_id(config.get_primary_section_id())
            .build()?;

        plex.fetch_machine_identifier().await?;
        plex.fetch_music_sections().await?;
        plex.fetch_collections().await?;
        plex.fetch_playlists().await?;

        Ok(plex)
    }

    pub async fn new_for_config(plex_url: &Url, plex_token: &PlexToken) -> Result<Self> {
        let client = HttpClient::new(plex_url.as_str(), plex_token.as_str())?;

        let mut plex = PlexClientBuilder::default()
            .client(client)
            .plex_token(plex_token.to_owned())
            .plex_url(plex_url.to_owned())
            .build()?;

        plex.fetch_music_sections().await?;

        Ok(plex)
    }

    async fn fetch_collections(&mut self) -> Result<()> {
        let resp: PlexResponse<Vec<Collection>> = self
            .client
            .get(
                &format!("library/sections/{}/collections", self.primary_section_id),
                None,
                None,
            )
            .await?;

        self.collections = resp.media_container.metadata;

        Ok(())
    }

    pub async fn fetch_collection(&self, collection_id: &str) -> Result<Collection> {
        let resp: PlexResponse<Vec<Collection>> = self
            .client
            .get(&format!("library/collections/{collection_id}"), None, None)
            .await?;

        let collection = resp.media_container.metadata.first().unwrap().to_owned();
        Ok(collection)
    }

    pub fn get_collections(&self) -> Vec<Collection> {
        self.collections.clone()
    }

    pub async fn fetch_music_sections(&mut self) -> Result<()> {
        let resp: SectionResponse = self.client.get("library/sections", None, None).await?;

        let sections = resp.media_container.directory;
        self.sections = sections
            .into_iter()
            .filter(|s| s.is_type_music())
            .collect::<_>();

        Ok(())
    }

    pub fn get_music_sections(&self) -> &[Section] {
        &self.sections
    }

    async fn fetch_playlists(&mut self) -> Result<()> {
        let resp: PlexResponse<Vec<Playlist>> = self.client.get("playlists", None, None).await?;

        self.playlists = resp.media_container.metadata;
        Ok(())
    }

    pub fn get_playlists(&self) -> &[Playlist] {
        &self.playlists
    }

    pub fn get_playlist(&self, playlist_id: &PlexId) -> &Playlist {
        self.playlists
            .iter()
            .find(|p| p.get_id() == playlist_id.as_str())
            .unwrap()
    }

    pub async fn fetch_playlist_items(&self, playlist_id: &PlexId) -> Result<Vec<Track>> {
        let resp: PlexResponse<Vec<Track>> = self
            .client
            .get(&format!("playlists/{playlist_id}/items"), None, None)
            .await?;
        Ok(resp.media_container.metadata)
    }

    pub async fn fetch_music(
        &self,
        filters: HashMap<String, String>,
        sort: Vec<&str>,
        max_results: Option<i32>,
    ) -> Result<Vec<Track>> {
        let sort = &sort.join(",");

        let mut params = HashMap::new();
        params.insert("type".to_string(), "10".to_string());
        params.insert("sort".to_string(), sort.to_string());
        params.extend(filters);

        let resp: Result<PlexResponse<Vec<Track>>> = self
            .client
            .get("library/sections/5/all", Some(params), max_results)
            .await;

        match resp {
            Ok(resp) => Ok(resp.media_container.metadata),
            Err(err) => {
                error!("An error occurred while attempting to fetch tracks:\n{err}");
                Err(err)
            }
        }
    }

    pub async fn update_playlist(
        &self,
        playlist_id: &PlexId,
        tracks: &[Track],
        summary: &str,
    ) -> Result<()> {
        info!("Wiping destination playlist...");
        self.clear_playlist(playlist_id).await?;

        info!("Updating destination playlist...");
        let ids = tracks
            .iter()
            .map(|t| t.get_id().to_string())
            .collect::<Vec<_>>();
        for chunk in ids.chunks(200) {
            self.add_items_to_playlist(playlist_id, chunk).await?;
        }

        self.update_summary(playlist_id, summary).await?;

        Ok(())
    }

    pub async fn update_summary(&self, playlist_id: &PlexId, summary: &str) -> Result<()> {
        let params = HashMap::from([("summary".to_string(), summary.to_string())]);

        let _: () = self
            .client
            .put(&format!("playlists/{}", playlist_id), Some(params))
            .await?;

        Ok(())
    }

    pub async fn create_playlist(&self, profile: &Profile) -> Result<String> {
        let params = HashMap::from([
            (
                "uri".to_string(),
                format!("{}/library/metadata", self.uri_root(),),
            ),
            ("title".to_string(), profile.get_title().to_string()),
            // ("summary".to_string(), urlencoding::encode(profile.get_summary()).to_string()),
            ("smart".to_string(), "0".to_string()),
            ("type".to_string(), "audio".to_string()),
        ]);

        let playlist: PlexResponse<Vec<NewPlaylist>> =
            self.client.post("playlists", Some(params)).await?;
        let playlist = playlist.media_container.metadata.first().unwrap();

        Ok(playlist.rating_key.to_string())
    }

    pub async fn add_items_to_playlist(
        &self,
        playlist_id: &PlexId,
        items: &[String],
    ) -> Result<()> {
        if items.is_empty() {
            return Err(anyhow!("There are no items to add to the playlist"));
        }

        for chunk in items.chunks(200) {
            let params = HashMap::from([(
                "uri".to_string(),
                format!("{}/library/metadata/{}", self.uri_root(), chunk.join(",")),
            )]);

            let _: PlexResponse<Vec<NewPlaylist>> = self
                .client
                .put(&format!("playlists/{playlist_id}/items"), Some(params))
                .await?;
        }

        Ok(())
    }

    pub async fn fetch_artists_from_collection(
        &self,
        collection: &Collection,
    ) -> Result<Vec<String>> {
        let artists = match collection.get_subtype() {
            SubType::Artist => {
                let resp: PlexResponse<Vec<Artist>> = self
                    .client
                    .get(
                        &format!("library/collections/{}/children", collection.get_id()),
                        None,
                        None,
                    )
                    .await?;

                resp.media_container
                    .metadata
                    .into_iter()
                    .map(|item| item.get_id().to_owned())
                    .collect::<_>()
            }
            SubType::Track => {
                let resp: PlexResponse<Vec<Track>> = self
                    .client
                    .get(
                        &format!("library/collections/{}/children", collection.get_id()),
                        None,
                        None,
                    )
                    .await?;

                resp.media_container
                    .metadata
                    .iter()
                    .map(|track| track.get_artist_id().to_owned())
                    .collect_vec()
                    .into_iter()
                    .sorted()
                    .dedup()
                    .collect_vec()
            }
        };

        Ok(artists)
    }

    pub async fn search_for_artist(&self, artist: &str) -> Result<Vec<Artist>> {
        let params = HashMap::from([("title".to_string(), artist.to_string())]);

        let resp: PlexResponse<Vec<Artist>> = self
            .client
            .get(
                &format!("/library/sections/{}/all", self.primary_section_id),
                Some(params),
                Some(10),
            )
            .await?;

        Ok(resp.media_container.metadata)
    }

    pub async fn clear_playlist(&self, playlist_id: &PlexId) -> Result<()> {
        self.client
            .delete(&format!("playlists/{playlist_id}/items"), None)
            .await?;
        Ok(())
    }

    async fn fetch_machine_identifier(&mut self) -> Result<()> {
        debug!("Fetching machine identifier...");

        #[derive(Default, Deserialize)]
        struct Identity {
            #[serde(alias = "machineIdentifier")]
            machine_identifier: String,
        }

        let resp: MediaContainerWrapper<Identity> = self.client.get("identity", None, None).await?;
        self.machine_identifier = resp.media_container.machine_identifier;

        Ok(())
    }

    fn uri_root(&self) -> String {
        format!(
            "server://{}/com.plexapp.plugins.library",
            &self.machine_identifier
        )
    }
}