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:
2026-03-20 09:46:15 +02:00
commit b11a0b7c56
26 changed files with 4131 additions and 0 deletions

20
crates/buh-cli/Cargo.toml Normal file
View 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 }

View File

@@ -0,0 +1,4 @@
pub async fn run() -> anyhow::Result<()> {
tracing::info!("client mode not yet implemented");
Ok(())
}

View 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(())
}

View 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,
}
}

View 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 }

View 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
}
}

View File

@@ -0,0 +1,7 @@
[package]
name = "buh-entity"
edition.workspace = true
version.workspace = true
[dependencies]
serde = { workspace = true }

View 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,
}

View File

@@ -0,0 +1,4 @@
pub mod config;
pub mod media;
pub mod rule;
pub mod torrent;

View 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>,
}

View 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,
}

View 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,
}

View 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 }

View File

@@ -0,0 +1,4 @@
pub mod processor;
pub mod rules;
pub mod scraper;
pub mod sync;

View File

@@ -0,0 +1 @@
//! Post-download file processing and renaming.

View File

@@ -0,0 +1 @@
//! Rule evaluation engine for torrent selection and discard.

View File

@@ -0,0 +1 @@
//! Scraper routines for torrent indexers.

View File

@@ -0,0 +1 @@
//! File synchronization to LAN targets.

16
crates/buh-ws/Cargo.toml Normal file
View 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
View 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(())
}