11use std:: {
2+ collections:: HashSet ,
23 fs:: File ,
34 io:: { BufWriter , Write as _} ,
45 ops:: RangeInclusive ,
@@ -22,6 +23,7 @@ use mlm_mam::{
2223} ;
2324use mlm_parse:: normalize_title;
2425use native_db:: { Database , db_type, transaction:: RwTransaction } ;
26+ use qbit:: models:: Torrent as QbitTorrent ;
2527use 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) ]
704831pub 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+ }
0 commit comments