use crate::prelude::*;
use lava_torrent::bencode::BencodeElem;
use lava_torrent::torrent::v1::Torrent as LavaTorrent;
const EXTENSIONS: &[&str] = &[".flac", ".mp3"];
pub(crate) struct CrossSeedChecker {
pub(crate) api: Ref<GazelleClient>,
pub(crate) main: Indexer,
pub(crate) cross: Indexer,
}
impl CrossSeedChecker {
pub(crate) async fn execute(
&self,
torrent_path: &Path,
source: &Source,
) -> Result<Option<u32>, Failure<CrossSeedAction>> {
let torrent = LavaTorrent::read_from_file(torrent_path).map_err(
Failure::wrap_with_path(CrossSeedAction::ReadTorrent, torrent_path),
)?;
if let Some(id) = self.check_by_hash(&torrent, source).await? {
return Ok(Some(id));
}
self.check_by_filelist(source).await
}
async fn check_by_hash(
&self,
torrent: &LavaTorrent,
source: &Source,
) -> Result<Option<u32>, Failure<CrossSeedAction>> {
let hash = compute_cross_hash(torrent, &self.cross.to_uppercase());
match self.api.get_torrent_by_hash(&hash).await {
Ok(response) => {
debug!(
"{} cross indexer match by hash for torrent {}",
"Found".bold(),
source.torrent.id
);
Ok(Some(response.torrent.id))
}
Err(error) if error.is_missing() => Ok(None),
Err(error) => Err(Failure::new(CrossSeedAction::HashLookup, error)
.with("torrent_id", source.torrent.id.to_string())),
}
}
async fn check_by_filelist(
&self,
source: &Source,
) -> Result<Option<u32>, Failure<CrossSeedAction>> {
let source_files = source.torrent.get_files();
let Some(filename) = select_search_filename(&source_files) else {
return Ok(None);
};
let results = self.get_candidates(source, filename).await?;
for group in &results.results {
for candidate in &group.torrents {
if !passes_prefilter(candidate, source.torrent.size, source.torrent.file_count) {
continue;
}
let response = self.api.get_torrent(candidate.torrent_id).await.map_err(
Failure::wrap_with(CrossSeedAction::GetTorrent, |f| {
f.with("torrent_id", candidate.torrent_id.to_string())
}),
)?;
if response.torrent.file_path != source.torrent.file_path {
continue;
}
if response.torrent.encoding != source.torrent.encoding {
continue;
}
if response.torrent.get_files() == source_files {
debug!(
"{} cross indexer match by filelist for torrent {}",
"Found".bold(),
source.torrent.id
);
return Ok(Some(response.torrent.id));
}
}
}
Ok(None)
}
async fn get_candidates(
&self,
source: &Source,
filename: String,
) -> Result<BrowseResponse, Failure<CrossSeedAction>> {
let cross_release_type_id = self.convert_release_type(source.group.release_type);
let request = BrowseRequest {
category: Some(Category::Music),
filelist: Some(filename),
media: Some(source.torrent.media.clone()),
release_type: cross_release_type_id,
..BrowseRequest::default()
};
let results = self
.api
.browse(&request)
.await
.map_err(Failure::wrap(CrossSeedAction::Browse))?;
Ok(results)
}
fn convert_release_type(&self, id: ReleaseTypeId) -> Option<ReleaseTypeId> {
let release_type = match self.main {
Indexer::Ops => ReleaseType::from_int_ops(id)?,
_ => ReleaseType::from_int_red(id)?,
};
match self.cross {
Indexer::Ops => release_type.to_id_ops(),
_ => release_type.to_id_red(),
}
}
}
pub(crate) fn passes_prefilter(
candidate: &BrowseTorrent,
source_size: u64,
source_file_count: u32,
) -> bool {
candidate.size == source_size && candidate.file_count == source_file_count
}
fn select_search_filename(files: &[FileItem]) -> Option<String> {
files
.iter()
.filter(|item| {
let lower = item.name.to_lowercase();
EXTENSIONS.iter().any(|ext| lower.ends_with(ext))
})
.max_by_key(|item| item.name.len())
.map(|item| item.name.clone())
}
fn compute_cross_hash(torrent: &LavaTorrent, target_source: &str) -> String {
let mut torrent = torrent.clone();
let fields = torrent.extra_info_fields.get_or_insert_with(HashMap::new);
fields.insert(
"source".to_owned(),
BencodeElem::String(target_source.to_owned()),
);
torrent.info_hash()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ThisError)]
pub(crate) enum CrossSeedAction {
#[error("read source torrent file")]
ReadTorrent,
#[error("look up torrent by hash on cross indexer")]
HashLookup,
#[error("browse cross indexer")]
Browse,
#[error("get torrent from cross indexer")]
GetTorrent,
}
#[cfg(test)]
mod tests {
use super::*;
use gazelle_api::{
ApiResponseError, BrowseGroup, ErrorSource, GazelleError, MockGazelleClient,
TorrentResponse,
};
use lava_torrent::torrent::v1::TorrentBuilder;
use std::fs::write as fs_write;
#[test]
fn passes_prefilter_matching_candidate() {
let candidate = BrowseTorrent {
size: 1_000,
file_count: 10,
..BrowseTorrent::mock()
};
assert!(passes_prefilter(&candidate, 1_000, 10));
}
#[test]
fn passes_prefilter_rejects_wrong_size() {
let candidate = BrowseTorrent {
size: 999,
file_count: 10,
..BrowseTorrent::mock()
};
assert!(!passes_prefilter(&candidate, 1_000, 10));
}
#[test]
fn passes_prefilter_rejects_wrong_file_count() {
let candidate = BrowseTorrent {
size: 1_000,
file_count: 9,
..BrowseTorrent::mock()
};
assert!(!passes_prefilter(&candidate, 1_000, 10));
}
#[test]
fn select_search_filename_picks_longest_music_file() {
let files = vec![
FileItem {
name: "short.flac".to_owned(),
size: 100,
},
FileItem {
name: "a very long track name indeed.flac".to_owned(),
size: 200,
},
FileItem {
name: "cover.jpg".to_owned(),
size: 50,
},
];
let output = select_search_filename(&files);
assert_eq!(
output,
Some("a very long track name indeed.flac".to_owned())
);
}
#[test]
fn select_search_filename_no_music_returns_none() {
let files = vec![
FileItem {
name: "cover.jpg".to_owned(),
size: 50,
},
FileItem {
name: "booklet.pdf".to_owned(),
size: 1000,
},
];
assert!(select_search_filename(&files).is_none());
}
#[test]
fn compute_cross_hash_differs_when_source_changes() {
let dir = TempDirectory::create("compute_cross_hash_differs_when_source_changes");
fs_write(dir.join("track.flac"), b"x").expect("write");
let torrent = TorrentBuilder::new(&*dir, 16384)
.set_announce(Some("https://example.com/announce".to_owned()))
.set_privacy(true)
.add_extra_info_field("source".to_owned(), BencodeElem::String("RED".to_owned()))
.build()
.expect("build");
let red_hash = compute_cross_hash(&torrent, "RED");
let ops_hash = compute_cross_hash(&torrent, "OPS");
assert_ne!(red_hash, ops_hash);
}
#[test]
fn cross_seed_checker_convert_release_type_red_to_ops() {
let checker = CrossSeedChecker {
api: mock_api(MockGazelleClient::new()),
main: Indexer::Red,
cross: Indexer::Ops,
};
let output = checker.convert_release_type(ReleaseTypeId::from_int(1));
assert!(output.is_some());
let red_album =
ReleaseType::from_int_red(ReleaseTypeId::from_int(1)).expect("RED ID 1 is Album");
assert_eq!(output, red_album.to_id_ops());
}
#[test]
fn cross_seed_checker_convert_release_type_unknown() {
let checker = CrossSeedChecker {
api: mock_api(MockGazelleClient::new()),
main: Indexer::Red,
cross: Indexer::Ops,
};
assert!(
checker
.convert_release_type(ReleaseTypeId::from_int(9999))
.is_none()
);
}
#[tokio::test]
async fn cross_seed_checker_execute_hash_match() {
let mut expected = TorrentResponse::mock();
expected.torrent.id = 987_654;
let mock = MockGazelleClient::new().with_get_torrent_by_hash(Ok(expected.clone()));
let checker = CrossSeedChecker {
api: mock_api(mock),
main: Indexer::Red,
cross: Indexer::Ops,
};
let source = Source::mock();
let dir = TempDirectory::create("cross_seed_checker_execute_hash_match");
fs_write(dir.join("t.flac"), b"x").expect("write");
let torrent_path = dir.join("source.torrent");
let torrent_bytes = TorrentBuilder::new(&*dir, 16384)
.set_announce(Some("https://example.com/announce".to_owned()))
.set_privacy(true)
.add_extra_info_field("source".to_owned(), BencodeElem::String("RED".to_owned()))
.build()
.expect("build")
.encode()
.expect("encode");
fs_write(&torrent_path, &torrent_bytes).expect("write torrent file");
let output = checker
.execute(&torrent_path, &source)
.await
.expect("execute");
assert_eq!(output, Some(987_654));
}
#[tokio::test]
async fn cross_seed_checker_execute_filelist_match() {
let source = Source::mock();
let browse = BrowseResponse {
results: vec![BrowseGroup {
torrents: vec![BrowseTorrent {
torrent_id: 555,
size: source.torrent.size,
file_count: source.torrent.file_count,
..BrowseTorrent::mock()
}],
..BrowseGroup::mock()
}],
..BrowseResponse::mock()
};
let matching = TorrentResponse {
torrent: Torrent {
id: 555,
file_path: source.torrent.file_path.clone(),
file_list: source.torrent.file_list.clone(),
encoding: source.torrent.encoding.clone(),
..Torrent::mock()
},
..TorrentResponse::mock()
};
let mock = MockGazelleClient::new()
.with_get_torrent_by_hash(Err(not_found_error()))
.with_browse(Ok(browse))
.with_get_torrent(Ok(matching));
let checker = CrossSeedChecker {
api: mock_api(mock),
main: Indexer::Red,
cross: Indexer::Ops,
};
let torrent_path = write_mock_torrent();
let output = checker
.execute(torrent_path.path(), &source)
.await
.expect("execute");
assert_eq!(output, Some(555));
}
#[tokio::test]
async fn cross_seed_checker_execute_filelist_mismatch() {
let source = Source::mock();
let browse = BrowseResponse {
results: vec![BrowseGroup {
torrents: vec![BrowseTorrent {
torrent_id: 555,
size: source.torrent.size,
file_count: source.torrent.file_count,
..BrowseTorrent::mock()
}],
..BrowseGroup::mock()
}],
..BrowseResponse::mock()
};
let differing = TorrentResponse {
torrent: Torrent {
id: 555,
file_path: source.torrent.file_path.clone(),
file_list: "different.flac{{{999999}}}|||".to_owned(),
encoding: source.torrent.encoding.clone(),
..Torrent::mock()
},
..TorrentResponse::mock()
};
let mock = MockGazelleClient::new()
.with_get_torrent_by_hash(Err(not_found_error()))
.with_browse(Ok(browse))
.with_get_torrent(Ok(differing));
let checker = CrossSeedChecker {
api: mock_api(mock),
main: Indexer::Red,
cross: Indexer::Ops,
};
let torrent_path = write_mock_torrent();
let output = checker
.execute(torrent_path.path(), &source)
.await
.expect("execute");
assert_eq!(output, None);
}
#[expect(
clippy::as_conversions,
reason = "required for trait object boxing in test"
)]
fn mock_api(mock: MockGazelleClient) -> Ref<GazelleClient> {
Ref::new(Box::new(mock) as GazelleClient)
}
fn not_found_error() -> GazelleError {
GazelleError {
operation: GazelleOperation::ApiResponse(ApiResponseKind::NotFound),
source: ErrorSource::ApiResponse(ApiResponseError {
message: "not found".to_owned(),
status: 404,
}),
}
}
fn write_mock_torrent() -> MockTorrentFile {
let dir = TempDirectory::create("write_mock_torrent");
fs_write(dir.join("t.flac"), b"x").expect("write");
let torrent_path = dir.join("source.torrent");
let torrent_bytes = TorrentBuilder::new(&*dir, 16384)
.set_announce(Some("https://example.com/announce".to_owned()))
.set_privacy(true)
.add_extra_info_field("source".to_owned(), BencodeElem::String("RED".to_owned()))
.build()
.expect("build")
.encode()
.expect("encode");
fs_write(&torrent_path, &torrent_bytes).expect("write torrent file");
MockTorrentFile { dir, torrent_path }
}
struct MockTorrentFile {
#[expect(dead_code, reason = "kept to retain ownership of the temp directory")]
dir: TempDirectory,
torrent_path: PathBuf,
}
impl MockTorrentFile {
fn path(&self) -> &Path {
&self.torrent_path
}
}
}