Initial commit

This commit is contained in:
2025-05-03 15:19:56 -06:00
commit 90c7b9d654
48 changed files with 3192 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
[package]
name = "flix-cli"
version = "0.0.1"
categories = ["command-line-utilities"]
description = "CLI for interacting with flix media"
repository = "https://github.com/QuantumShade/flix"
authors.workspace = true
edition.workspace = true
license-file.workspace = true
rust-version.workspace = true
[[bin]]
doc = false
name = "flix"
path = "src/main.rs"
[lints.rust]
arithmetic_overflow = "forbid"
unsafe_code = "forbid"
[lints.clippy]
arithmetic_side_effects = "deny"
as_conversions = "deny"
checked_conversions = "deny"
default_union_representation = "deny"
expect_used = "deny"
indexing_slicing = "deny"
integer_division = "deny"
integer_division_remainder_used = "deny"
transmute_undefined_repr = "deny"
unchecked_duration_subtraction = "deny"
unwrap_used = "deny"
[dependencies]
flix = { workspace = true, features = ["tmdb"] }
flix-tmdb = { workspace = true }
anyhow = { workspace = true }
clap = { workspace = true, features = [
"derive",
"color",
"error-context",
"help",
"suggestions",
"usage",
] }
home = { workspace = true }
serde = { workspace = true, features = ["derive"] }
tokio = { workspace = true, features = ["rt", "fs", "macros"] }
toml = { workspace = true, features = ["display", "parse"] }
+5
View File
@@ -0,0 +1,5 @@
# flix-cli
[![Crates Version](https://img.shields.io/crates/v/flix-cli.svg)](https://crates.io/crates/flix-cli)
CLI for interacting with flix media
+68
View File
@@ -0,0 +1,68 @@
use std::path::PathBuf;
use clap::{Parser, Subcommand};
pub mod tmdb;
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
/// Use a custom config file [default: ~/.flix]
#[arg(short, long, value_name = "FILE")]
config: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
impl Cli {
pub fn config_path(&self) -> PathBuf {
match self.config.as_ref() {
Some(path) => match path.strip_prefix("~/") {
Ok(path) => expect_home_dir().join(path),
Err(_) => path.to_owned(),
},
None => expect_home_dir().join(".flix"),
}
}
pub fn command(&self) -> &Command {
&self.command
}
}
#[derive(Subcommand)]
pub enum Command {
/// Print a flix manifest
Print {
#[command(subcommand)]
command: BackendCommand,
},
/// Write a flix manifest if the destination does not exist
Write {
/// Overwrite the destination
#[arg(short, long, default_value_t = false)]
force: bool,
/// Change the destination
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
#[command(subcommand)]
command: BackendCommand,
},
}
#[derive(Subcommand)]
pub enum BackendCommand {
/// Use the TMDB backend
Tmdb {
#[command(subcommand)]
command: tmdb::Command,
},
}
fn expect_home_dir() -> PathBuf {
#[allow(clippy::expect_used)]
home::home_dir().expect("you do not have a home directory")
}
+36
View File
@@ -0,0 +1,36 @@
use clap::Subcommand;
#[derive(Subcommand)]
pub enum Command {
/// Process a TMDB collection
Collection {
#[arg(value_name = "TMDB_ID")]
id: i32,
},
/// Process a TMDB movie
Movie {
#[arg(value_name = "TMDB_ID")]
id: i32,
},
/// Process a TMDB show
Show {
#[arg(value_name = "TMDB_ID")]
id: i32,
},
/// Process a TMDB season
Season {
#[arg(value_name = "TMDB_ID")]
id: i32,
#[arg(value_name = "SEASON_NUM")]
season: i32,
},
/// Process a TMDB episode
Episode {
#[arg(value_name = "TMDB_ID")]
id: i32,
#[arg(value_name = "SEASON_NUM")]
season: i32,
#[arg(value_name = "EPISODE_NUM")]
episode: i32,
},
}
+21
View File
@@ -0,0 +1,21 @@
#[derive(serde::Deserialize)]
pub struct Config {
tmdb: TmdbConfig,
}
#[derive(serde::Deserialize)]
pub struct TmdbConfig {
bearer_token: String,
}
impl Config {
pub fn tmdb(&self) -> &TmdbConfig {
&self.tmdb
}
}
impl TmdbConfig {
pub fn bearer_token(&self) -> &str {
&self.bearer_token
}
}
+65
View File
@@ -0,0 +1,65 @@
use flix_tmdb::Client;
use anyhow::{Context, Result};
use clap::Parser;
use tokio::fs;
mod cli;
use cli::{BackendCommand, Cli, Command};
mod config;
use config::Config;
use tokio::io::AsyncWriteExt;
mod run;
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
let cli = Cli::parse();
let config = fs::read_to_string(cli.config_path())
.await
.with_context(|| format!("could not read config: {:?}", cli.config_path()))?;
let config: Config = toml::from_str(config.as_str())
.with_context(|| format!("could not parse config: {:?}", cli.config_path()))?;
let client = Client::new(config.tmdb().bearer_token().to_owned());
match cli.command() {
Command::Print { command } => match command {
BackendCommand::Tmdb { command } => {
let object = run::tmdb::TmdbObject::fetch(&client, command).await?;
println!("{}", object.serialize().context("failed to serialize")?)
}
},
Command::Write {
force,
output,
command,
} => match command {
BackendCommand::Tmdb { command } => {
let object = run::tmdb::TmdbObject::fetch(&client, command).await?;
let output = output
.as_ref()
.map(|p| p.as_path())
.unwrap_or(object.default_filename());
let mut file = if *force {
fs::File::create(output).await
} else {
fs::File::create_new(output).await
}
.with_context(|| format!("could not create file at path {}", output.display()))?;
file.write_all(
object
.serialize()
.context("failed to serialize tmdb object")?
.as_bytes(),
)
.await
.with_context(|| format!("could not write to file at path {}", output.display()))?;
}
},
}
Ok(())
}
+1
View File
@@ -0,0 +1 @@
pub mod tmdb;
+134
View File
@@ -0,0 +1,134 @@
use std::path::Path;
use flix::model::{
Collection, Episode, GenericCollection, GenericEpisode, GenericMovie, GenericSeason,
GenericShow, Movie, Season, Show, TmdbCollection, TmdbMovie, TmdbShow,
};
use flix_tmdb::Client;
use flix_tmdb::model::{
Collection as TCollection, Episode as TEpisode, Movie as TMovie, Season as TSeason,
Show as TShow,
};
use anyhow::{Context, Result};
use crate::cli::tmdb::Command;
pub enum TmdbObject {
Collection(TCollection),
Movie(TMovie),
Show(TShow),
Season(TSeason),
Episode(TEpisode),
}
impl TmdbObject {
pub fn default_filename(&self) -> &'static Path {
Path::new(match self {
TmdbObject::Collection(_) => "collection.toml",
TmdbObject::Movie(_) => "movie.toml",
TmdbObject::Show(_) => "show.toml",
TmdbObject::Season(_) => "season.toml",
TmdbObject::Episode(_) => "episode.toml",
})
}
pub fn serialize(self) -> Result<String> {
Ok(match self {
TmdbObject::Collection(tmdb) => toml::to_string(&Collection {
collection: GenericCollection {
name: tmdb.name,
overview: tmdb.overview,
},
tmdb: Some(TmdbCollection { id: tmdb.id }),
})?,
TmdbObject::Movie(tmdb) => toml::to_string(&Movie {
movie: GenericMovie {
title: tmdb.title,
overview: tmdb.overview,
release_date: tmdb.release_date,
},
tmdb: Some(TmdbMovie {
id: tmdb.id,
collection: tmdb.collection.map(|c| c.id),
genres: tmdb.genres.iter().map(|g| g.id).collect(),
}),
})?,
TmdbObject::Show(tmdb) => toml::to_string(&Show {
show: GenericShow {
name: tmdb.name,
overview: tmdb.overview,
air_date: tmdb.first_air_date,
},
tmdb: Some(TmdbShow {
id: tmdb.id,
genres: tmdb.genres.iter().map(|g| g.id).collect(),
}),
})?,
TmdbObject::Season(tmdb) => toml::to_string(&Season {
season: GenericSeason {
number: tmdb.season_number,
name: tmdb.name,
overview: tmdb.overview,
air_date: tmdb.air_date,
},
})?,
TmdbObject::Episode(tmdb) => toml::to_string(&Episode {
episode: GenericEpisode {
number: tmdb.episode_number,
name: tmdb.name,
overview: tmdb.overview,
air_date: tmdb.air_date,
},
})?,
})
}
}
impl TmdbObject {
pub async fn fetch(client: &Client, command: &Command) -> Result<Self> {
Ok(match *command {
Command::Collection { id } => Self::Collection(
client
.collections()
.get_details(id, None)
.await
.with_context(|| format!("could not get collection details for '{id}'"))?,
),
Command::Movie { id } => Self::Movie(
client
.movies()
.get_details(id, None)
.await
.with_context(|| format!("could not get movie details for '{id}'"))?,
),
Command::Show { id } => Self::Show(
client
.shows()
.get_details(id, None)
.await
.with_context(|| format!("could not get show details for '{id}'"))?,
),
Command::Season { id, season } => Self::Season(
client
.seasons()
.get_details(id, season, None)
.await
.with_context(|| format!("could not get show details for '{id}' S{season}"))?,
),
Command::Episode {
id,
season,
episode,
} => Self::Episode(
client
.episodes()
.get_details(id, season, episode, None)
.await
.with_context(|| {
format!("could not get show details for '{id}' S{season}E{episode}")
})?,
),
})
}
}