Skip to content

Commit 83555dd

Browse files
committed
Allow switching category automatically to make torrents linked
1 parent 1251f4c commit 83555dd

5 files changed

Lines changed: 296 additions & 15 deletions

File tree

mlm_core/src/autograbber.rs

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::{
2+
collections::HashSet,
23
fs::File,
34
io::{BufWriter, Write as _},
45
ops::RangeInclusive,
@@ -22,6 +23,7 @@ use mlm_mam::{
2223
};
2324
use mlm_parse::normalize_title;
2425
use native_db::{Database, db_type, transaction::RwTransaction};
26+
use qbit::models::Torrent as QbitTorrent;
2527
use tokio::{
2628
fs,
2729
sync::{MutexGuard, watch::Sender},
@@ -34,6 +36,7 @@ use crate::{
3436
audiobookshelf::{self as abs, Abs},
3537
config::{Config, Cost, SortBy, TorrentFilter, TorrentSearch, Type},
3638
logging::write_event,
39+
qbittorrent::{ensure_category_exists, get_torrent},
3740
torrent_downloader::get_mam_torrent_file,
3841
};
3942

@@ -700,6 +703,130 @@ pub struct PendingTorrentMetaUpdate {
700703
mam_id: Option<u64>,
701704
}
702705

706+
fn linked_library_categories(config: &Config) -> HashSet<&str> {
707+
config
708+
.libraries
709+
.iter()
710+
.filter_map(|library| match library {
711+
crate::config::Library::ByCategory(category) => Some(category.category.as_str()),
712+
_ => None,
713+
})
714+
.collect()
715+
}
716+
717+
fn matching_tag_categories<'a>(config: &'a Config, meta: &TorrentMeta) -> Vec<&'a str> {
718+
config
719+
.tags
720+
.iter()
721+
.filter(|tag| tag.filter.matches_meta(meta).is_ok_and(|matches| matches))
722+
.filter_map(|tag| tag.category.as_deref())
723+
.collect()
724+
}
725+
726+
fn replacement_category_for_ignored_torrent<'a>(
727+
config: &'a Config,
728+
meta: &TorrentMeta,
729+
current_category: Option<&str>,
730+
) -> Option<&'a str> {
731+
let linked_categories = linked_library_categories(config);
732+
let matching_categories = matching_tag_categories(config, meta);
733+
let target_category = matching_categories
734+
.iter()
735+
.copied()
736+
.find(|category| linked_categories.contains(category))?;
737+
738+
let Some(current_category) = current_category.filter(|category| !category.is_empty()) else {
739+
return Some(target_category);
740+
};
741+
742+
if linked_categories.contains(current_category)
743+
|| matching_categories.contains(&current_category)
744+
|| current_category == target_category
745+
{
746+
return None;
747+
}
748+
749+
Some(target_category)
750+
}
751+
752+
fn category_library_accepts_torrent(
753+
config: &Config,
754+
torrent: &QbitTorrent,
755+
category: &str,
756+
) -> bool {
757+
config.libraries.iter().any(|library| {
758+
let crate::config::Library::ByCategory(library) = library else {
759+
return false;
760+
};
761+
if library.category != category {
762+
return false;
763+
}
764+
765+
let filters = &library.tag_filters;
766+
if filters
767+
.deny_tags
768+
.iter()
769+
.any(|tag| torrent.tags.split(",").any(|t| t.trim() == tag.as_str()))
770+
{
771+
return false;
772+
}
773+
if filters.allow_tags.is_empty() {
774+
return true;
775+
}
776+
filters
777+
.allow_tags
778+
.iter()
779+
.any(|tag| torrent.tags.split(",").any(|t| t.trim() == tag.as_str()))
780+
})
781+
}
782+
783+
async fn update_ignored_qbit_category(
784+
config: &Config,
785+
db: &Database<'_>,
786+
torrent: &mlm_db::Torrent,
787+
meta: &TorrentMeta,
788+
) -> Result<()> {
789+
if !torrent.id_is_hash {
790+
return Ok(());
791+
}
792+
793+
let Some((qbit_torrent, qbit, qbit_conf)) = get_torrent(config, &torrent.id).await? else {
794+
return Ok(());
795+
};
796+
797+
let new_category = replacement_category_for_ignored_torrent(
798+
config,
799+
meta,
800+
Some(qbit_torrent.category.as_str()),
801+
);
802+
let Some(new_category) = new_category else {
803+
return Ok(());
804+
};
805+
let mut replacement_torrent = qbit_torrent.clone();
806+
replacement_torrent.category = new_category.to_string();
807+
if !category_library_accepts_torrent(config, &replacement_torrent, new_category) {
808+
warn!(
809+
"Skipping qBittorrent category update for torrent {} ({}): category '{}' is linked but rejected by library tag filters for tags '{}'",
810+
torrent.id, meta.title, new_category, qbit_torrent.tags
811+
);
812+
return Ok(());
813+
}
814+
815+
ensure_category_exists(&qbit, &qbit_conf.url, new_category).await?;
816+
qbit.set_category(Some(vec![torrent.id.as_str()]), new_category)
817+
.await?;
818+
819+
let (_guard, rw) = db.rw_async().await?;
820+
let Some(mut updated_torrent) = rw.get().primary::<mlm_db::Torrent>(torrent.id.clone())? else {
821+
return Ok(());
822+
};
823+
updated_torrent.category = Some(new_category.to_string());
824+
rw.upsert(updated_torrent)?;
825+
rw.commit()?;
826+
827+
Ok(())
828+
}
829+
703830
#[allow(clippy::too_many_arguments)]
704831
pub fn queue_torrent_meta_update(
705832
rw: &RwTransaction<'_>,
@@ -808,6 +935,13 @@ pub async fn finalize_torrent_meta_update(
808935
} = pending;
809936
let id = torrent.id.clone();
810937

938+
if let Err(err) = update_ignored_qbit_category(config, db, &torrent, &meta).await {
939+
warn!(
940+
"Failed updating qBittorrent category for torrent {} ({} ) after metadata commit: {err}",
941+
torrent.id, meta.title
942+
);
943+
}
944+
811945
if let Some(library_path) = &torrent.library_path
812946
&& let serde_json::Value::Object(new) = abs::create_metadata(&meta)
813947
{
@@ -951,3 +1085,136 @@ fn add_duplicate_torrent(
9511085
})?;
9521086
Ok(())
9531087
}
1088+
1089+
#[cfg(test)]
1090+
mod tests {
1091+
use super::{category_library_accepts_torrent, replacement_category_for_ignored_torrent};
1092+
use crate::config::{
1093+
Config, Library, LibraryByCategory, LibraryLinkMethod, LibraryOptions, LibraryTagFilters,
1094+
};
1095+
use mlm_db::{MediaType, TorrentMeta};
1096+
use qbit::models::Torrent as QbitTorrent;
1097+
use std::path::PathBuf;
1098+
1099+
fn test_config() -> Config {
1100+
let mut config = Config::default();
1101+
config.tags = vec![
1102+
crate::config::TagFilter {
1103+
filter: crate::config::TorrentFilter {
1104+
edition: crate::config::EditionFilter {
1105+
media_type: vec![MediaType::Audiobook],
1106+
..Default::default()
1107+
},
1108+
..Default::default()
1109+
},
1110+
category: Some("linked".to_string()),
1111+
tags: vec![],
1112+
},
1113+
crate::config::TagFilter {
1114+
filter: crate::config::TorrentFilter {
1115+
edition: crate::config::EditionFilter {
1116+
media_type: vec![MediaType::Audiobook],
1117+
..Default::default()
1118+
},
1119+
..Default::default()
1120+
},
1121+
category: Some("ignored-but-still-matching".to_string()),
1122+
tags: vec![],
1123+
},
1124+
];
1125+
config.libraries = vec![Library::ByCategory(LibraryByCategory {
1126+
category: "linked".to_string(),
1127+
options: LibraryOptions {
1128+
name: Some("linked".to_string()),
1129+
library_dir: PathBuf::from("/library"),
1130+
method: LibraryLinkMethod::Hardlink,
1131+
audio_types: None,
1132+
ebook_types: None,
1133+
},
1134+
tag_filters: Default::default(),
1135+
})];
1136+
config
1137+
}
1138+
1139+
fn audiobook_meta() -> TorrentMeta {
1140+
TorrentMeta {
1141+
media_type: MediaType::Audiobook,
1142+
..Default::default()
1143+
}
1144+
}
1145+
1146+
#[test]
1147+
fn replaces_empty_category_with_linked_category() {
1148+
let config = test_config();
1149+
assert_eq!(
1150+
replacement_category_for_ignored_torrent(&config, &audiobook_meta(), None),
1151+
Some("linked")
1152+
);
1153+
}
1154+
1155+
#[test]
1156+
fn replaces_unlinked_ignored_category_with_linked_category() {
1157+
let config = test_config();
1158+
assert_eq!(
1159+
replacement_category_for_ignored_torrent(
1160+
&config,
1161+
&audiobook_meta(),
1162+
Some("completely-ignored")
1163+
),
1164+
Some("linked")
1165+
);
1166+
}
1167+
1168+
#[test]
1169+
fn keeps_already_linked_category() {
1170+
let config = test_config();
1171+
assert_eq!(
1172+
replacement_category_for_ignored_torrent(&config, &audiobook_meta(), Some("linked")),
1173+
None
1174+
);
1175+
}
1176+
1177+
#[test]
1178+
fn keeps_category_that_still_matches_a_tag_rule() {
1179+
let config = test_config();
1180+
assert_eq!(
1181+
replacement_category_for_ignored_torrent(
1182+
&config,
1183+
&audiobook_meta(),
1184+
Some("ignored-but-still-matching")
1185+
),
1186+
None
1187+
);
1188+
}
1189+
1190+
#[test]
1191+
fn rejects_replacement_category_when_library_tag_filters_do_not_match() {
1192+
let mut config = Config::default();
1193+
config.libraries = vec![Library::ByCategory(LibraryByCategory {
1194+
category: "linked".to_string(),
1195+
options: LibraryOptions {
1196+
name: Some("linked".to_string()),
1197+
library_dir: PathBuf::from("/library"),
1198+
method: LibraryLinkMethod::Hardlink,
1199+
audio_types: None,
1200+
ebook_types: None,
1201+
},
1202+
tag_filters: LibraryTagFilters {
1203+
allow_tags: vec!["wanted".to_string()],
1204+
deny_tags: vec![],
1205+
},
1206+
})];
1207+
1208+
let qbit_torrent = QbitTorrent {
1209+
category: "linked".to_string(),
1210+
tags: "other-tag".to_string(),
1211+
..Default::default()
1212+
};
1213+
1214+
assert!(!category_library_accepts_torrent(
1215+
&config,
1216+
&qbit_torrent,
1217+
"linked"
1218+
));
1219+
}
1220+
}

mlm_web_api/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ pub fn router(context: Context, dioxus_public_path: PathBuf) -> Router {
4040
"/assets",
4141
ServiceBuilder::new()
4242
.layer(middleware::from_fn(set_static_cache_control))
43-
.service(ServeDir::new(dioxus_assets_path).fallback(ServeDir::new("server/assets"))),
43+
.service(
44+
ServeDir::new(dioxus_assets_path).fallback(ServeDir::new("server/assets")),
45+
),
4446
);
4547

4648
#[cfg(debug_assertions)]

mlm_web_askama/src/lib.rs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
mod pages;
22
mod tables;
33

4+
use crate::pages::{
5+
index::stats_updates,
6+
search::{search_page, search_page_post},
7+
};
48
use askama::{Template, filters::HtmlSafe};
59
use axum::{
610
Router,
@@ -10,6 +14,8 @@ use axum::{
1014
routing::{get, post},
1115
};
1216
use itertools::Itertools;
17+
use mlm_core::config::{SearchConfig, TorrentFilter};
18+
use mlm_core::{Context, ContextExt};
1319
use mlm_db::{
1420
AudiobookCategory, EbookCategory, Flags, SelectedTorrent, Series, Timestamp, Torrent,
1521
TorrentMeta,
@@ -35,20 +41,17 @@ use time::{
3541
format_description::{self, OwnedFormatItem},
3642
};
3743
use tokio::sync::watch::error::SendError;
38-
use crate::pages::{
39-
index::stats_updates,
40-
search::{search_page, search_page_post},
41-
};
42-
use mlm_core::config::{SearchConfig, TorrentFilter};
43-
use mlm_core::{Context, ContextExt};
4444

4545
pub fn router(context: Context) -> Router {
4646
Router::new()
4747
.route(
4848
"/old/stats-updates",
4949
get(stats_updates).with_state(context.clone()),
5050
)
51-
.route("/old/torrents", get(torrents_page).with_state(context.clone()))
51+
.route(
52+
"/old/torrents",
53+
get(torrents_page).with_state(context.clone()),
54+
)
5255
.route(
5356
"/old/torrents",
5457
post(torrents_page_post).with_state(context.clone()),
@@ -69,7 +72,10 @@ pub fn router(context: Context) -> Router {
6972
"/old/torrents/{id}/edit",
7073
post(torrent_edit_page_post).with_state(context.clone()),
7174
)
72-
.route("/old/events", get(event_page).with_state(context.db().clone()))
75+
.route(
76+
"/old/events",
77+
get(event_page).with_state(context.db().clone()),
78+
)
7379
.route("/old/search", get(search_page).with_state(context.clone()))
7480
.route(
7581
"/old/search",
@@ -84,12 +90,18 @@ pub fn router(context: Context) -> Router {
8490
"/old/lists/{list_id}",
8591
post(list_page_post).with_state(context.db().clone()),
8692
)
87-
.route("/old/errors", get(errors_page).with_state(context.db().clone()))
93+
.route(
94+
"/old/errors",
95+
get(errors_page).with_state(context.db().clone()),
96+
)
8897
.route(
8998
"/old/errors",
9099
post(errors_page_post).with_state(context.db().clone()),
91100
)
92-
.route("/old/selected", get(selected_page).with_state(context.clone()))
101+
.route(
102+
"/old/selected",
103+
get(selected_page).with_state(context.clone()),
104+
)
93105
.route(
94106
"/old/selected",
95107
post(selected_torrents_page_post).with_state(context.db().clone()),

0 commit comments

Comments
 (0)