@@ -7,13 +7,20 @@ use std::time::Duration;
77use librqbit:: api:: Api ;
88use librqbit:: http_api:: { HttpApi , HttpApiOptions } ;
99use librqbit:: { AddTorrent , AddTorrentOptions , AddTorrentResponse , Session , SessionOptions } ;
10+ use once_cell:: sync:: Lazy ;
11+ use regex:: Regex ;
1012use reqwest:: Client ;
1113use thiserror:: Error ;
1214use tokio:: net:: TcpListener ;
1315use tokio:: process:: Command ;
1416use tokio:: time:: timeout;
1517use tracing:: { debug, info} ;
1618
19+ // Compiled regexes for episode parsing (compiled once at startup)
20+ static EPISODE_SXEX_RE : Lazy < Regex > =
21+ Lazy :: new ( || Regex :: new ( r"(?i)[Ss](\d{1,2})[Ee](\d{1,3})" ) . unwrap ( ) ) ;
22+ static EPISODE_X_RE : Lazy < Regex > = Lazy :: new ( || Regex :: new ( r"(?i)(\d{1,2})x(\d{1,3})" ) . unwrap ( ) ) ;
23+
1724#[ derive( Error , Debug ) ]
1825pub enum StreamError {
1926 #[ error( "failed to create streaming session: {0}" ) ]
@@ -860,20 +867,16 @@ pub struct VideoFile {
860867impl VideoFile {
861868 /// Extract season and episode numbers from filename for sorting
862869 pub fn episode_sort_key ( & self ) -> ( u32 , u32 ) {
863- use regex:: Regex ;
864-
865870 // S01E02 format
866- let sxex_re = Regex :: new ( r"(?i)[Ss](\d{1,2})[Ee](\d{1,3})" ) . unwrap ( ) ;
867- if let Some ( caps) = sxex_re. captures ( & self . name )
871+ if let Some ( caps) = EPISODE_SXEX_RE . captures ( & self . name )
868872 && let ( Some ( s) , Some ( e) ) = ( caps. get ( 1 ) , caps. get ( 2 ) )
869873 && let ( Ok ( season) , Ok ( episode) ) = ( s. as_str ( ) . parse ( ) , e. as_str ( ) . parse ( ) )
870874 {
871875 return ( season, episode) ;
872876 }
873877
874878 // 1x02 format
875- let x_re = Regex :: new ( r"(?i)(\d{1,2})x(\d{1,3})" ) . unwrap ( ) ;
876- if let Some ( caps) = x_re. captures ( & self . name )
879+ if let Some ( caps) = EPISODE_X_RE . captures ( & self . name )
877880 && let ( Some ( s) , Some ( e) ) = ( caps. get ( 1 ) , caps. get ( 2 ) )
878881 && let ( Ok ( season) , Ok ( episode) ) = ( s. as_str ( ) . parse ( ) , e. as_str ( ) . parse ( ) )
879882 {
@@ -1225,4 +1228,233 @@ mod tests {
12251228 assert ! ( v. matches( "Spider-Man.No.Way.Home.2021.mkv" ) ) ;
12261229 assert ! ( v. matches( "The.Amazing.Spider-Man.2021.mkv" ) ) ; // "spider" matches
12271230 }
1231+
1232+ #[ test]
1233+ fn test_torrent_validation_empty_keywords ( ) {
1234+ // Empty keywords should match everything (year still applies)
1235+ let v = TorrentValidation :: new ( vec ! [ ] , Some ( 2024 ) ) ;
1236+ assert ! ( v. matches( "Anything.2024.mkv" ) ) ;
1237+ assert ! ( !v. matches( "Anything.2023.mkv" ) ) ;
1238+
1239+ // Empty keywords, no year - matches everything
1240+ let v = TorrentValidation :: new ( vec ! [ ] , None ) ;
1241+ assert ! ( v. matches( "Anything.mkv" ) ) ;
1242+ assert ! ( v. matches( "Random.File.2024.mkv" ) ) ;
1243+ }
1244+
1245+ #[ test]
1246+ fn test_torrent_validation_case_insensitive ( ) {
1247+ let v = TorrentValidation :: new ( vec ! [ "matrix" . to_string( ) ] , Some ( 1999 ) ) ;
1248+ assert ! ( v. matches( "The.MATRIX.1999.mkv" ) ) ;
1249+ assert ! ( v. matches( "matrix.1999.mkv" ) ) ;
1250+ assert ! ( v. matches( "MATRIX.1999.MKV" ) ) ;
1251+ }
1252+
1253+ #[ test]
1254+ fn test_torrent_validation_partial_match ( ) {
1255+ let v = TorrentValidation :: new ( vec ! [ "inception" . to_string( ) ] , Some ( 2010 ) ) ;
1256+ assert ! ( v. matches( "Inception.2010.1080p.mkv" ) ) ;
1257+ // Should match if keyword is substring
1258+ assert ! ( v. matches( "The.Inception.Of.Dreams.2010.mkv" ) ) ;
1259+ }
1260+
1261+ #[ test]
1262+ fn test_extract_keywords_filters_short_words ( ) {
1263+ let kw = TorrentValidation :: extract_keywords ( "Go To It Now" ) ;
1264+ assert ! ( !kw. contains( & "go" . to_string( ) ) ) ; // Too short (2 chars)
1265+ assert ! ( !kw. contains( & "to" . to_string( ) ) ) ; // Stop word
1266+ assert ! ( !kw. contains( & "it" . to_string( ) ) ) ; // Too short
1267+ assert ! ( kw. contains( & "now" . to_string( ) ) ) ; // 3 chars minimum
1268+ }
1269+
1270+ #[ test]
1271+ fn test_extract_keywords_handles_special_chars ( ) {
1272+ let kw = TorrentValidation :: extract_keywords ( "Batman: The Dark Knight!" ) ;
1273+ assert ! ( kw. contains( & "batman" . to_string( ) ) ) ;
1274+ assert ! ( kw. contains( & "dark" . to_string( ) ) ) ;
1275+ assert ! ( kw. contains( & "knight" . to_string( ) ) ) ;
1276+ }
1277+
1278+ #[ test]
1279+ fn test_extract_keywords_filters_years ( ) {
1280+ // 4-digit numbers should be filtered out
1281+ let kw = TorrentValidation :: extract_keywords ( "Movie 2024 2023 1999" ) ;
1282+ assert ! ( !kw. contains( & "2024" . to_string( ) ) ) ;
1283+ assert ! ( !kw. contains( & "2023" . to_string( ) ) ) ;
1284+ assert ! ( !kw. contains( & "1999" . to_string( ) ) ) ;
1285+
1286+ // But non-year numbers are OK
1287+ let kw = TorrentValidation :: extract_keywords ( "Apollo 13 Mission" ) ;
1288+ assert ! ( kw. contains( & "apollo" . to_string( ) ) ) ;
1289+ assert ! ( kw. contains( & "mission" . to_string( ) ) ) ;
1290+ // "13" is filtered (too short)
1291+ }
1292+
1293+ #[ test]
1294+ fn test_episode_sort_key_1x02_format ( ) {
1295+ let file = VideoFile {
1296+ name : "Show.1x05.720p.mkv" . to_string ( ) ,
1297+ file_idx : 0 ,
1298+ size : 1000 ,
1299+ stream_url : String :: new ( ) ,
1300+ } ;
1301+ assert_eq ! ( file. episode_sort_key( ) , ( 1 , 5 ) ) ;
1302+ }
1303+
1304+ #[ test]
1305+ fn test_episode_sort_key_no_pattern ( ) {
1306+ let file = VideoFile {
1307+ name : "Movie.2024.1080p.mkv" . to_string ( ) ,
1308+ file_idx : 0 ,
1309+ size : 1000 ,
1310+ stream_url : String :: new ( ) ,
1311+ } ;
1312+ // Should return max values when no episode pattern found
1313+ assert_eq ! ( file. episode_sort_key( ) , ( u32 :: MAX , u32 :: MAX ) ) ;
1314+ }
1315+
1316+ #[ test]
1317+ fn test_episode_sort_key_double_digit_season ( ) {
1318+ let file = VideoFile {
1319+ name : "Show.S12E34.mkv" . to_string ( ) ,
1320+ file_idx : 0 ,
1321+ size : 1000 ,
1322+ stream_url : String :: new ( ) ,
1323+ } ;
1324+ assert_eq ! ( file. episode_sort_key( ) , ( 12 , 34 ) ) ;
1325+ }
1326+
1327+ #[ test]
1328+ fn test_video_file_extensions ( ) {
1329+ assert ! ( is_video_file( "test.mp4" ) ) ;
1330+ assert ! ( is_video_file( "test.mkv" ) ) ;
1331+ assert ! ( is_video_file( "test.avi" ) ) ;
1332+ assert ! ( is_video_file( "test.mov" ) ) ;
1333+ assert ! ( is_video_file( "test.wmv" ) ) ;
1334+ assert ! ( is_video_file( "test.flv" ) ) ;
1335+ assert ! ( is_video_file( "test.webm" ) ) ;
1336+ assert ! ( is_video_file( "test.m4v" ) ) ;
1337+
1338+ // Case insensitive
1339+ assert ! ( is_video_file( "test.MKV" ) ) ;
1340+ assert ! ( is_video_file( "test.Mp4" ) ) ;
1341+ }
1342+
1343+ #[ test]
1344+ fn test_subtitle_file_extensions ( ) {
1345+ assert ! ( is_subtitle_file( "test.srt" ) ) ;
1346+ assert ! ( is_subtitle_file( "test.ass" ) ) ;
1347+ assert ! ( is_subtitle_file( "test.ssa" ) ) ;
1348+ assert ! ( is_subtitle_file( "test.sub" ) ) ;
1349+ assert ! ( is_subtitle_file( "test.vtt" ) ) ;
1350+
1351+ // Case insensitive
1352+ assert ! ( is_subtitle_file( "test.SRT" ) ) ;
1353+ assert ! ( is_subtitle_file( "test.Ass" ) ) ;
1354+ }
1355+
1356+ #[ test]
1357+ fn test_extract_subtitle_language_dotted_format ( ) {
1358+ assert_eq ! (
1359+ extract_subtitle_language( "Movie.en.srt" ) ,
1360+ Some ( "en" . to_string( ) )
1361+ ) ;
1362+ assert_eq ! (
1363+ extract_subtitle_language( "Movie.es.srt" ) ,
1364+ Some ( "es" . to_string( ) )
1365+ ) ;
1366+ assert_eq ! (
1367+ extract_subtitle_language( "Movie.fr.srt" ) ,
1368+ Some ( "fr" . to_string( ) )
1369+ ) ;
1370+ }
1371+
1372+ #[ test]
1373+ fn test_extract_subtitle_language_priority ( ) {
1374+ // Should match first pattern found (english before eng before .en.)
1375+ let result = extract_subtitle_language ( "Movie.english.eng.en.srt" ) ;
1376+ assert_eq ! ( result, Some ( "en" . to_string( ) ) ) ;
1377+ }
1378+
1379+ #[ test]
1380+ fn test_extract_subtitle_language_case_insensitive ( ) {
1381+ assert_eq ! (
1382+ extract_subtitle_language( "Movie.ENGLISH.srt" ) ,
1383+ Some ( "en" . to_string( ) )
1384+ ) ;
1385+ assert_eq ! (
1386+ extract_subtitle_language( "Movie.ENG.srt" ) ,
1387+ Some ( "en" . to_string( ) )
1388+ ) ;
1389+ }
1390+
1391+ #[ test]
1392+ fn test_calculate_progress ( ) {
1393+ assert_eq ! ( calculate_progress( 50.0 , 100.0 ) , 50.0 ) ;
1394+ assert_eq ! ( calculate_progress( 0.0 , 100.0 ) , 0.0 ) ;
1395+ assert_eq ! ( calculate_progress( 100.0 , 100.0 ) , 100.0 ) ;
1396+ assert_eq ! ( calculate_progress( 150.0 , 100.0 ) , 100.0 ) ; // Clamped to 100
1397+ assert_eq ! ( calculate_progress( 50.0 , 0.0 ) , 0.0 ) ; // Division by zero guard
1398+ }
1399+
1400+ #[ test]
1401+ fn test_sort_episodes_mixed_seasons ( ) {
1402+ let mut files = vec ! [
1403+ VideoFile {
1404+ name: "Show.S02E01.mkv" . to_string( ) ,
1405+ file_idx: 0 ,
1406+ size: 1000 ,
1407+ stream_url: String :: new( ) ,
1408+ } ,
1409+ VideoFile {
1410+ name: "Show.S01E03.mkv" . to_string( ) ,
1411+ file_idx: 1 ,
1412+ size: 1000 ,
1413+ stream_url: String :: new( ) ,
1414+ } ,
1415+ VideoFile {
1416+ name: "Show.S01E01.mkv" . to_string( ) ,
1417+ file_idx: 2 ,
1418+ size: 1000 ,
1419+ stream_url: String :: new( ) ,
1420+ } ,
1421+ ] ;
1422+
1423+ sort_episodes ( & mut files) ;
1424+
1425+ assert ! ( files[ 0 ] . name. contains( "S01E01" ) ) ;
1426+ assert ! ( files[ 1 ] . name. contains( "S01E03" ) ) ;
1427+ assert ! ( files[ 2 ] . name. contains( "S02E01" ) ) ;
1428+ }
1429+
1430+ #[ test]
1431+ fn test_sort_episodes_movies_at_end ( ) {
1432+ let mut files = vec ! [
1433+ VideoFile {
1434+ name: "Movie.2024.mkv" . to_string( ) ,
1435+ file_idx: 0 ,
1436+ size: 1000 ,
1437+ stream_url: String :: new( ) ,
1438+ } ,
1439+ VideoFile {
1440+ name: "Show.S01E02.mkv" . to_string( ) ,
1441+ file_idx: 1 ,
1442+ size: 1000 ,
1443+ stream_url: String :: new( ) ,
1444+ } ,
1445+ VideoFile {
1446+ name: "Show.S01E01.mkv" . to_string( ) ,
1447+ file_idx: 2 ,
1448+ size: 1000 ,
1449+ stream_url: String :: new( ) ,
1450+ } ,
1451+ ] ;
1452+
1453+ sort_episodes ( & mut files) ;
1454+
1455+ // Episodes should come first, movie last (u32::MAX sort key)
1456+ assert ! ( files[ 0 ] . name. contains( "S01E01" ) ) ;
1457+ assert ! ( files[ 1 ] . name. contains( "S01E02" ) ) ;
1458+ assert ! ( files[ 2 ] . name. contains( "Movie" ) ) ;
1459+ }
12281460}
0 commit comments