use crate::prelude::*;
#[cfg(test)]
use di::existing_as_self;
use di::{Injectable, Mut, ServiceCollection, ServiceProvider, singleton_as_self};
#[cfg(test)]
use gazelle_api::MockGazelleClient;
use qbittorrent_api::{QBittorrentClientFactory, QBittorrentClientOptions};
use rogue_logging::InitLog;
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(Shutdown::singleton())
.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(AuditCommand::transient())
.add(TorrentAuditor::transient())
.add(BatchCommand::transient())
.add(ConfigCommand::transient())
.add(CrossCommand::transient())
.add(singleton_as_self().from(cross_factory))
.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(TorrentInjector::transient())
.add(UploadCommand::transient())
.add(ApiVerifier::transient())
.add(ContentVerifier::transient())
.add(DecodeVerifier::transient())
.add(FlacVerifier::transient())
.add(TorrentFileProvider::transient())
.add(VerifyCommand::transient())
.add(ReportRenderer::transient())
.add(SourceReporter::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: MockGazelleClient) -> &mut Self {
let client: Ref<GazelleClient> = Ref::new(Box::new(client) as GazelleClient);
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"
)]
pub fn with_mock_cross_client(&mut self, client: MockGazelleClient) -> &mut Self {
let api: Ref<GazelleClient> = Ref::new(Box::new(client) as GazelleClient);
self.services.add(singleton_as_self().from(move |services| {
let main_options = services.get_required::<SharedOptions>();
let cache_options = services.get_required::<CacheOptions>();
let file_options = services.get_required::<FileOptions>();
let cross_config_options = services.get_required::<CrossConfigOptions>();
Ref::new(CrossServices::mock(
main_options,
cache_options,
file_options,
cross_config_options,
api.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<QbitClient> = Ref::new(Box::new(client) as QbitClient);
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 {
let output_dir = test_dir.output();
let cache_dir = test_dir.cache();
let reports_dir = test_dir.reports();
tokio_create_dir_all(&output_dir)
.await
.expect("should be able to create output dir");
tokio_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 })
.with_options(ReportOptions {
reports_dir,
no_reports: false,
})
}
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<QbitClient> {
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 QbitClient)
}
#[expect(clippy::as_conversions, reason = "required for traits")]
fn gazelle_factory(services: &ServiceProvider) -> Ref<GazelleClient> {
let options = services.get_required::<SharedOptions>();
let indexer = options.get_indexer();
let (num, per) = indexer.gazelle_rate_limit();
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: Some(num),
request_limit_duration: Some(per),
retry_delays: indexer.gazelle_retry_delays(),
},
};
Ref::new(Box::new(factory.create()) as GazelleClient)
}
fn cross_factory(services: &ServiceProvider) -> Ref<Option<CrossServices>> {
let main_options = services.get_required::<SharedOptions>();
let cache_options = services.get_required::<CacheOptions>();
let file_options = services.get_required::<FileOptions>();
let cross_config_options = services.get_required::<CrossConfigOptions>();
Ref::new(CrossServices::create(
main_options,
cache_options,
file_options,
cross_config_options,
))
}
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))
}
fn semaphore_factory(services: &ServiceProvider) -> Ref<Semaphore> {
let options = services.get_required::<RunnerOptions>();
Ref::new(Semaphore::new(options.get_cpus()))
}