Initial workspace scaffold
Cargo workspace with 5 crates: buh-entity (pure data structs), buh-data (Turso/libsql data access), buh-util (scraper, rules, processor, sync modules), buh-cli (binary "buh" with client/daemon subcommands), and buh-ws (axum WebSocket server). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
20
crates/buh-cli/Cargo.toml
Normal file
20
crates/buh-cli/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "buh-cli"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "buh"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
buh-entity = { workspace = true }
|
||||
buh-data = { workspace = true }
|
||||
buh-util = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
4
crates/buh-cli/src/client.rs
Normal file
4
crates/buh-cli/src/client.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub async fn run() -> anyhow::Result<()> {
|
||||
tracing::info!("client mode not yet implemented");
|
||||
Ok(())
|
||||
}
|
||||
24
crates/buh-cli/src/daemon.rs
Normal file
24
crates/buh-cli/src/daemon.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use std::path::Path;
|
||||
|
||||
use buh_entity::config::DaemonConfig;
|
||||
|
||||
pub async fn run(config_path: &Path) -> anyhow::Result<()> {
|
||||
let raw = std::fs::read_to_string(config_path)?;
|
||||
let config: DaemonConfig = toml::from_str(&raw)?;
|
||||
|
||||
let db = buh_data::Db::connect(&config.database.url, config.database.auth_token.as_deref())
|
||||
.await?;
|
||||
db.migrate().await?;
|
||||
|
||||
if config.routines.scrape {
|
||||
tracing::info!("scrape routine: not yet implemented");
|
||||
}
|
||||
if config.routines.process {
|
||||
tracing::info!("process routine: not yet implemented");
|
||||
}
|
||||
if config.routines.sync {
|
||||
tracing::info!("sync routine: not yet implemented");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
37
crates/buh-cli/src/main.rs
Normal file
37
crates/buh-cli/src/main.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
mod client;
|
||||
mod daemon;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "buh", version, about = "Media automation engine")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Interactive client (default when no subcommand is given)
|
||||
Client,
|
||||
/// Run daemon routines from a configuration file
|
||||
Daemon {
|
||||
/// Path to daemon TOML configuration file
|
||||
#[arg(long, default_value = "/etc/buh/daemon.toml")]
|
||||
config: std::path::PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command.unwrap_or(Command::Client) {
|
||||
Command::Client => client::run().await,
|
||||
Command::Daemon { config } => daemon::run(&config).await,
|
||||
}
|
||||
}
|
||||
11
crates/buh-data/Cargo.toml
Normal file
11
crates/buh-data/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "buh-data"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
buh-entity = { workspace = true }
|
||||
libsql = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
77
crates/buh-data/src/lib.rs
Normal file
77
crates/buh-data/src/lib.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use libsql::Connection;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DataError {
|
||||
#[error("database error: {0}")]
|
||||
Database(#[from] libsql::Error),
|
||||
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
pub struct Db {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub async fn connect(url: &str, auth_token: Option<&str>) -> Result<Self, DataError> {
|
||||
let db = match auth_token {
|
||||
Some(token) => {
|
||||
libsql::Builder::new_remote(url.to_string(), token.to_string())
|
||||
.build()
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
libsql::Builder::new_local(url)
|
||||
.build()
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let conn = db.connect()?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub async fn migrate(&self) -> Result<(), DataError> {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS torrents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
info_hash TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
media_type TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'discovered',
|
||||
source_url TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
pattern TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
media_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
year INTEGER,
|
||||
torrent_id INTEGER REFERENCES torrents(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
",
|
||||
)
|
||||
.await?;
|
||||
tracing::info!("database migrations applied");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn conn(&self) -> &Connection {
|
||||
&self.conn
|
||||
}
|
||||
}
|
||||
7
crates/buh-entity/Cargo.toml
Normal file
7
crates/buh-entity/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "buh-entity"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
39
crates/buh-entity/src/config.rs
Normal file
39
crates/buh-entity/src/config.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::media::MediaType;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct DaemonConfig {
|
||||
pub database: DatabaseConfig,
|
||||
pub indexers: Vec<IndexerConfig>,
|
||||
pub sync: Vec<SyncTarget>,
|
||||
pub routines: RoutineConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct DatabaseConfig {
|
||||
pub url: String,
|
||||
pub auth_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct IndexerConfig {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub api_key: Option<String>,
|
||||
pub media_types: Vec<MediaType>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct SyncTarget {
|
||||
pub name: String,
|
||||
pub media_type: MediaType,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct RoutineConfig {
|
||||
pub scrape: bool,
|
||||
pub process: bool,
|
||||
pub sync: bool,
|
||||
}
|
||||
4
crates/buh-entity/src/lib.rs
Normal file
4
crates/buh-entity/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod config;
|
||||
pub mod media;
|
||||
pub mod rule;
|
||||
pub mod torrent;
|
||||
19
crates/buh-entity/src/media.rs
Normal file
19
crates/buh-entity/src/media.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum MediaType {
|
||||
Show,
|
||||
Movie,
|
||||
Music,
|
||||
Book,
|
||||
AudioBook,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MediaItem {
|
||||
pub id: Option<i64>,
|
||||
pub media_type: MediaType,
|
||||
pub title: String,
|
||||
pub year: Option<u16>,
|
||||
}
|
||||
20
crates/buh-entity/src/rule.rs
Normal file
20
crates/buh-entity/src/rule.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::media::MediaType;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum RuleAction {
|
||||
Ingest,
|
||||
Discard,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Rule {
|
||||
pub id: Option<i64>,
|
||||
pub name: String,
|
||||
pub media_type: MediaType,
|
||||
pub action: RuleAction,
|
||||
pub pattern: String,
|
||||
pub priority: i32,
|
||||
}
|
||||
25
crates/buh-entity/src/torrent.rs
Normal file
25
crates/buh-entity/src/torrent.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::media::MediaType;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum TorrentState {
|
||||
Discovered,
|
||||
Queued,
|
||||
Downloading,
|
||||
Downloaded,
|
||||
Processed,
|
||||
Synced,
|
||||
Discarded,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Torrent {
|
||||
pub id: Option<i64>,
|
||||
pub info_hash: String,
|
||||
pub name: String,
|
||||
pub media_type: Option<MediaType>,
|
||||
pub state: TorrentState,
|
||||
pub source_url: String,
|
||||
}
|
||||
14
crates/buh-util/Cargo.toml
Normal file
14
crates/buh-util/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "buh-util"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
buh-entity = { workspace = true }
|
||||
buh-data = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
4
crates/buh-util/src/lib.rs
Normal file
4
crates/buh-util/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod processor;
|
||||
pub mod rules;
|
||||
pub mod scraper;
|
||||
pub mod sync;
|
||||
1
crates/buh-util/src/processor.rs
Normal file
1
crates/buh-util/src/processor.rs
Normal file
@@ -0,0 +1 @@
|
||||
//! Post-download file processing and renaming.
|
||||
1
crates/buh-util/src/rules.rs
Normal file
1
crates/buh-util/src/rules.rs
Normal file
@@ -0,0 +1 @@
|
||||
//! Rule evaluation engine for torrent selection and discard.
|
||||
1
crates/buh-util/src/scraper.rs
Normal file
1
crates/buh-util/src/scraper.rs
Normal file
@@ -0,0 +1 @@
|
||||
//! Scraper routines for torrent indexers.
|
||||
1
crates/buh-util/src/sync.rs
Normal file
1
crates/buh-util/src/sync.rs
Normal file
@@ -0,0 +1 @@
|
||||
//! File synchronization to LAN targets.
|
||||
16
crates/buh-ws/Cargo.toml
Normal file
16
crates/buh-ws/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "buh-ws"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
buh-entity = { workspace = true }
|
||||
buh-data = { workspace = true }
|
||||
buh-util = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
34
crates/buh-ws/src/main.rs
Normal file
34
crates/buh-ws/src/main.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::ws::{WebSocket, WebSocketUpgrade},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
};
|
||||
|
||||
async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
|
||||
ws.on_upgrade(handle_socket)
|
||||
}
|
||||
|
||||
async fn handle_socket(mut socket: WebSocket) {
|
||||
while let Some(Ok(msg)) = socket.recv().await {
|
||||
tracing::debug!(?msg, "received");
|
||||
if socket.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let app = Router::new().route("/ws", get(ws_handler));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:9000").await?;
|
||||
tracing::info!("buh-ws listening on {}", listener.local_addr()?);
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user