use crate::hosting::*;
use crate::prelude::*;
#[cfg(test)]
use di::existing_as_self;
use di::{Injectable, Mut, ServiceCollection, ServiceProvider, singleton_as_self};
use gazelle_api::{GazelleClientFactory, GazelleClientOptions, GazelleClientTrait};
use qbittorrent_api::{QBittorrentClientFactory, QBittorrentClientOptions, QBittorrentClientTrait};
use rogue_logging::{InitLog, Logger};
use std::fs::read_to_string;
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
pub struct HostBuilder {
pub services: ServiceCollection,
options: OptionsProvider,
}
impl Default for HostBuilder {
fn default() -> Self {
Self::new()
}
}
impl HostBuilder {
#[must_use]
pub(crate) fn new() -> Self {
let options = OptionsProvider::default();
Self::new_internal(options, None)
}
#[must_use]
pub fn new_cli() -> Self {
let args = Arc::new(ArgumentsProvider::new());
let yaml = read_config_file(&args);
let options = OptionsProvider::from_args(args.clone(), yaml);
Self::new_internal(options, Some(args))
}
#[must_use]
fn new_internal(mut options: OptionsProvider, args: Option<Arc<ArgumentsProvider>>) -> Self {
let mut services = ServiceCollection::new();
services.register_options(&mut options);
if let Some(args) = args {
let args = args.clone();
services.add(singleton_as_self().from(move |_| args.clone()));
}
services
.add(singleton_as_self().from(logger_factory))
.add(SoxFactory::singleton())
.add(PathManager::transient())
.add(IdProvider::transient())
.add(SourceProvider::transient())
.add(singleton_as_self().from(gazelle_factory))
.add(singleton_as_self().from(qbit_factory))
.add(JobRunner::transient())
.add(Publisher::transient())
.add(DebugSubscriber::transient())
.add(ProgressBarSubscriber::transient())
.add(ExistingFormatProvider::transient())
.add(TargetFormatProvider::transient())
.add(BatchCommand::transient())
.add(ConfigCommand::transient())
.add(DocsCommand::transient())
.add(InspectCommand::transient())
.add(QueueAddCommand::transient())
.add(QueueFetchCommand::transient())
.add(QueueListCommand::transient())
.add(QueueRemoveCommand::transient())
.add(QueueSummaryCommand::transient())
.add(Queue::singleton())
.add(SpectrogramCommand::transient())
.add(SpectrogramJobFactory::transient())
.add(singleton_as_self().from(semaphore_factory))
.add(singleton_as_self().from(joinset_factory))
.add(TranscodeCommand::transient())
.add(TranscodeJobFactory::transient())
.add(AdditionalJobFactory::transient())
.add(UploadCommand::transient())
.add(ApiVerifier::transient())
.add(TorrentFileProvider::transient())
.add(VerifyCommand::transient())
.add(VersionCommand::transient());
HostBuilder { services, options }
}
#[must_use]
#[cfg(test)]
pub fn with_options<T: Send + Sync + 'static>(&mut self, options: T) -> &mut Self {
self.services.add(existing_as_self(options));
self
}
#[must_use]
#[cfg(test)]
pub fn with_mock_api(&mut self, album_config: AlbumConfig) -> &mut Self {
self.with_mock_client(album_config.api())
}
#[must_use]
#[cfg(test)]
#[expect(
clippy::as_conversions,
reason = "required for DI trait object registration"
)]
pub fn with_mock_client(&mut self, client: gazelle_api::MockGazelleClient) -> &mut Self {
let client: Ref<Box<dyn GazelleClientTrait + Send + Sync>> =
Ref::new(Box::new(client) as Box<dyn GazelleClientTrait + Send + Sync>);
self.services
.add(singleton_as_self().from(move |_| client.clone()));
self
}
#[must_use]
#[cfg(test)]
#[expect(
clippy::as_conversions,
reason = "required for DI trait object registration"
)]
#[expect(
clippy::absolute_paths,
reason = "mock type is behind a feature flag and not re-exported at crate root"
)]
pub fn with_mock_torrent_client(
&mut self,
client: qbittorrent_api::mock::MockQBittorrentClient,
) -> &mut Self {
let client: Ref<Box<dyn QBittorrentClientTrait + Send + Sync>> =
Ref::new(Box::new(client) as Box<dyn QBittorrentClientTrait + Send + Sync>);
self.services
.add(singleton_as_self().from(move |_| client.clone()));
self
}
#[cfg(test)]
pub async fn with_test_options(&mut self, test_dir: &TestDirectory) -> &mut Self {
use tokio::fs::create_dir_all;
let output_dir = test_dir.output();
let cache_dir = test_dir.cache();
create_dir_all(&output_dir)
.await
.expect("should be able to create output dir");
create_dir_all(&cache_dir)
.await
.expect("should be able to create cache dir");
self.with_options(SharedOptions {
content: vec![SAMPLE_SOURCES_DIR.clone()],
output: output_dir,
..SharedOptions::mock()
})
.with_options(CacheOptions { cache: cache_dir })
}
pub fn build(&self) -> Result<Host, BuildError> {
if self.options.has_errors() {
return Err(BuildError::Options(self.options.errors.clone()));
}
let services = self.services.build_provider()?;
Ok(Host::new(services))
}
#[cfg(test)]
#[must_use]
#[expect(clippy::panic, reason = "intentional panic for test failures")]
pub fn expect_build(&self) -> Host {
match self.build() {
Ok(host) => host,
Err(error) => panic!("{error}"),
}
}
}
fn read_config_file(args: &ArgumentsProvider) -> Option<String> {
if !args.get_command().uses_options("ConfigOptions") {
return None;
}
let options = args.get_args::<ConfigOptionsPartial>().ok()?;
let path = options
.config
.clone()
.unwrap_or_else(PathManager::default_config_path);
read_to_string(path.expand_tilde()).ok()
}
#[expect(clippy::as_conversions, reason = "required for traits")]
fn qbit_factory(provider: &ServiceProvider) -> Arc<Box<dyn QBittorrentClientTrait + Send + Sync>> {
let options = provider.get_required::<QbitOptions>();
let client_options = match &options.qbit_url {
Some(url) => QBittorrentClientOptions {
host: url.clone(),
username: options.qbit_username.clone().unwrap_or_default(),
password: options.qbit_password.clone().unwrap_or_default(),
user_agent: Some(app_user_agent(true)),
..QBittorrentClientOptions::default()
},
None => QBittorrentClientOptions::default(),
};
let factory = QBittorrentClientFactory {
options: client_options,
};
Ref::new(Box::new(factory.create()) as Box<dyn QBittorrentClientTrait + Send + Sync>)
}
#[expect(clippy::as_conversions, reason = "required for traits")]
fn gazelle_factory(services: &ServiceProvider) -> Ref<Box<dyn GazelleClientTrait + Send + Sync>> {
let options = services.get_required::<SharedOptions>();
let factory = GazelleClientFactory {
options: GazelleClientOptions {
url: options.indexer_url.clone(),
key: options.api_key.clone(),
user_agent: app_user_agent(true),
requests_allowed_per_duration: None,
request_limit_duration: None,
},
};
Ref::new(Box::new(factory.create()) as Box<dyn GazelleClientTrait + Send + Sync>)
}
fn logger_factory(provider: &ServiceProvider) -> Ref<Logger> {
let options = provider.get_required::<SharedOptions>();
let logger = Ref::new(
default_logger()
.with_verbosity(options.verbosity)
.with_time_format(options.log_time)
.create(),
);
logger.clone().init();
logger
}
#[expect(clippy::type_complexity, reason = "collection of job results")]
fn joinset_factory(
_services: &ServiceProvider,
) -> Ref<Mut<JoinSet<Result<(), Failure<JobAction>>>>> {
let set: JoinSet<Result<(), Failure<JobAction>>> = JoinSet::new();
RefMut::new(Mut::new(set))
}
#[expect(clippy::as_conversions, reason = "u16 to usize is safe")]
fn semaphore_factory(services: &ServiceProvider) -> Ref<Semaphore> {
let options = services.get_required::<RunnerOptions>();
let cpus = options.cpus.expect("cpus should be set") as usize;
Ref::new(Semaphore::new(cpus))
}