use std::collections::HashSet;
use crate::prelude::*;
use gazelle_api::{Category, GazelleClientTrait, UploadForm};
use html_escape::decode_html_entities;
use qbittorrent_api::QBittorrentClientTrait;
use tokio::fs::{copy, hard_link};
#[injectable]
pub(crate) struct UploadCommand {
shared_options: Ref<SharedOptions>,
upload_options: Ref<UploadOptions>,
copy_options: Ref<CopyOptions>,
source_provider: Ref<SourceProvider>,
api: Ref<Box<dyn GazelleClientTrait + Send + Sync>>,
paths: Ref<PathManager>,
transcode_job_factory: Ref<TranscodeJobFactory>,
qbit_options: Ref<QbitOptions>,
qbit_upload_options: Ref<QbitUploadOptions>,
qbit: Ref<Box<dyn QBittorrentClientTrait + Send + Sync>>,
}
impl UploadCommand {
pub(crate) async fn execute_cli(&self) -> Result<bool, Failure<UploadAction>> {
let source = self
.source_provider
.get_from_options()
.await
.map_err(Failure::wrap(UploadAction::GetSource))?
.map_err(Failure::wrap(UploadAction::GetSource))?;
self.execute(&source).await?;
Ok(true)
}
#[allow(clippy::too_many_lines)]
pub(crate) async fn execute(
&self,
source: &Source,
) -> Result<UploadSuccess, Failure<UploadAction>> {
if self.qbit_upload_options.inject_torrent {
let mut errors: Vec<OptionRule> = Vec::new();
self.qbit_options.validate_connection(&mut errors);
if !errors.is_empty() {
OptionRule::show(&errors);
return Err(Failure::from_action(UploadAction::InjectTorrent));
}
}
let mut warnings = Vec::new();
let mut formats = Vec::new();
for &target in &source.targets {
let torrent_path = self.paths.get_torrent_path(source, target);
if !torrent_path.exists() {
warn!("In v0.19.0 the torrent file name format changed.");
warn!(
"Running the transcode command will update existing transcodes without re-transcoding."
);
return Err(
Failure::new(UploadAction::FindTorrent, UploadError::MissingTorrent)
.with_path(&torrent_path),
);
}
let target_dir = self.paths.get_transcode_target_dir(source, target);
trace!("{} content of {}", "Verifying".bold(), target_dir.display());
TorrentVerifier::execute(&torrent_path, &target_dir)
.await
.map_err(Failure::wrap(UploadAction::VerifyContent))?;
let form = UploadForm {
path: torrent_path.clone(),
category_id: Category::Music,
remaster_year: source.metadata.year,
remaster_title: source.torrent.remaster_title.clone(),
remaster_record_label: decode_html_entities(&source.torrent.remaster_record_label)
.to_string(),
remaster_catalogue_number: source.torrent.remaster_catalogue_number.clone(),
format: target.to_format(),
bitrate: target.to_quality(),
media: source.torrent.media.clone(),
release_desc: self.create_description(source, target),
group_id: source.group.id,
};
if self.upload_options.dry_run {
warn!("{} upload as this is a dry run", "Skipping".bold());
info!("{} data of {target} for {source}:", "Upload".bold());
info!("\n{form}");
continue;
}
if self.upload_options.copy_transcode_to_content_dir {
trace!("{} transcode to content directory", "Copying".bold());
let destination = self
.shared_options
.content
.first()
.expect("content should contain at least one directory");
if let Err(e) = self.copy_transcode(&target_dir, destination).await {
warn!("{}", e.render());
warnings.push(e.to_error());
}
}
if let Some(destination) = &self.upload_options.copy_transcode_to {
trace!(
"{} transcode to: {}",
"Copying".bold(),
destination.display(),
);
if let Err(e) = self.copy_transcode(&target_dir, destination).await {
warn!("{}", e.render());
warnings.push(e.to_error());
}
}
if let Some(torrent_dir) = &self.upload_options.copy_torrent_to
&& let Err(e) = self.copy_torrent(source, &target, torrent_dir).await
{
warn!("{}", e.render());
warnings.push(e.to_error());
}
let response = self
.api
.upload_torrent(form)
.await
.map_err(Failure::wrap(UploadAction::Upload))?;
info!("{} {target} for {source}", "Uploaded".bold());
let base = &self.shared_options.indexer_url;
let id = response.torrent_id;
let link = get_permalink(base, response.group_id, id);
info!("{link}");
if self.qbit_upload_options.inject_torrent {
let add_options = self.qbit_upload_options.to_add_torrent_options();
if let Err(e) = self
.qbit
.add_torrent(add_options, torrent_path.clone())
.await
.map_err(Failure::wrap(UploadAction::InjectTorrent))
{
warn!("{}", e.render());
warnings.push(e.to_error());
}
}
formats.push(UploadFormatStatus { format: target, id });
}
Ok(UploadSuccess { formats, warnings })
}
async fn copy_transcode(
&self,
source_path: &Path,
target_parent: &Path,
) -> Result<(), Failure<UploadAction>> {
let source_dir_name = source_path
.file_name()
.expect("source dir should have a name");
let target_dir = target_parent.join(source_dir_name);
if target_dir.exists() {
warn!(
"{} copy as the target directory already exists: {}",
"Skipping".bold(),
target_dir.display()
);
return Ok(());
}
let verb = if self.copy_options.hard_link {
copy_dir(source_path, &target_dir, true)
.await
.map_err(Failure::wrap(UploadAction::CopyTranscode))?;
"Hard Linked"
} else {
copy_dir(source_path, &target_dir, false)
.await
.map_err(Failure::wrap(UploadAction::CopyTranscode))?;
"Copied"
};
trace!(
"{} {} to {}",
verb.bold(),
source_path.display(),
target_dir.display()
);
Ok(())
}
async fn copy_torrent(
&self,
source: &Source,
target: &TargetFormat,
target_dir: &Path,
) -> Result<(), Failure<UploadAction>> {
let source_path = self.paths.get_torrent_path(source, *target);
let source_file_name = source_path
.file_name()
.expect("torrent path should have a name");
let target_path = target_dir.join(source_file_name);
let verb = if self.copy_options.hard_link {
hard_link(&source_path, &target_path)
.await
.map_err(Failure::wrap_with_path(
UploadAction::HardLinkTorrent,
&target_path,
))?;
"Hard Linked"
} else {
copy(&source_path, &target_path)
.await
.map_err(Failure::wrap_with_path(
UploadAction::CopyTorrent,
&target_path,
))?;
"Copied"
};
trace!(
"{} {} to {}",
verb.bold(),
source_path.display(),
target_path.display()
);
Ok(())
}
#[allow(clippy::uninlined_format_args)]
fn create_description(&self, source: &Source, target: TargetFormat) -> String {
let base = &self.shared_options.indexer_url;
let source_url = get_permalink(base, source.group.id, source.torrent.id);
let source_title = source.format.get_title();
let mut lines: Vec<String> = vec![
format!(
"Transcoded and uploaded with [url={}][b]{}[/b] {}[/url]",
APP_HOMEPAGE,
APP_NAME,
app_version_or_describe()
),
format!("[pad=0|10|0|20]Source[/pad] [url={source_url}]{source_title}[/url]"),
];
for transcode_command in self.get_commands(source, target) {
lines.push(format!(
"[pad=0|10|0|0]Transcode[/pad] [code]{transcode_command}[/code]"
));
}
let path = self.paths.get_transcode_target_dir(source, target);
let factory = InspectFactory::new(false);
let details = factory.create_split(&path);
match details {
Ok((properties, tags)) => {
lines.push(format!(
"[pad=0|10|0|19]Details[/pad] [pre]{properties}[/pre]"
));
lines.push(format!(
"[pad=0|10|0|31]Tags[/pad] [hide][pre]{tags}[/pre][/hide]"
));
}
Err(e) => {
warn!(
"Unable to add track details to upload description\n{}",
e.render()
);
}
}
lines.into_iter().fold(String::new(), |mut output, line| {
output.push_str("[quote]");
output.push_str(&line);
output.push_str("[/quote]");
output
})
}
pub(crate) fn get_commands(&self, source: &Source, target: TargetFormat) -> HashSet<String> {
let flacs = Collector::get_flacs(&source.directory);
flacs
.into_iter()
.filter_map(|flac| {
self.get_command_internal(flac, source, target)
.unwrap_or_else(|e| {
warn!("{}", e.render());
None
})
})
.collect()
}
fn get_command_internal(
&self,
flac: FlacFile,
source: &Source,
target: TargetFormat,
) -> Result<Option<String>, Failure<UploadAction>> {
let job = self
.transcode_job_factory
.create_single(0, &flac, source, target)
.map_err(Failure::wrap(UploadAction::GetTranscodeCommand))?;
let Job::Transcode(job) = job else {
unreachable!("TranscodeJobFactory::create_single always returns Job::Transcode")
};
let command = match job.variant {
Variant::Transcode(mut decode, mut encode) => {
decode.input = PathBuf::from("input.flac");
let extension = encode
.output
.extension()
.expect("output should have an extension")
.to_string_lossy();
encode.output = PathBuf::from(format!("output.{extension}"));
Some(format!(
"{} | {}",
decode.to_info().display(),
encode.to_info().display()
))
}
Variant::Resample(mut resample) => {
resample.input = PathBuf::from("input.flac");
let extension = resample
.output
.extension()
.expect("output should have an extension")
.to_string_lossy();
resample.output = PathBuf::from(format!("output.{extension}"));
Some(resample.to_info().display())
}
Variant::Include(_) => None,
};
Ok(command)
}
}