@@ -747,4 +747,215 @@ describe('videoDownloadPostProcessFiles', () => {
747747 expect ( moveCalls . length ) . toBe ( 0 ) ;
748748 } ) ;
749749 } ) ;
750+
751+ describe ( 'special character handling in channel names' , ( ) => {
752+ it ( 'uses filesystem path for channel with # character (temp downloads + subfolder)' , async ( ) => {
753+ // Scenario: Channel name in metadata is "Fred again . #" but yt-dlp sanitizes to "Fred again . ."
754+ const sanitizedChannelName = 'Fred again . .' ;
755+ const rawChannelName = 'Fred again . #' ;
756+ const tempVideoPath = `/tmp/youtarr-downloads/${ sanitizedChannelName } /Fred again . . - Video Title [abc123]/Video Title [abc123].mp4` ;
757+ const tempVideoDir = `/tmp/youtarr-downloads/${ sanitizedChannelName } /Fred again . . - Video Title [abc123]` ;
758+ process . argv = [ 'node' , 'script' , tempVideoPath ] ;
759+
760+ Channel . findOne . mockResolvedValue ( {
761+ sub_folder : 'Music' ,
762+ uploader : rawChannelName
763+ } ) ;
764+
765+ tempPathManager . isEnabled . mockReturnValue ( true ) ;
766+ tempPathManager . isTempPath . mockReturnValue ( true ) ;
767+
768+ fs . readFileSync . mockReturnValue ( JSON . stringify ( {
769+ id : 'abc123' ,
770+ title : 'Video Title' ,
771+ uploader : rawChannelName , // Raw name with #
772+ channel_id : 'channel123'
773+ } ) ) ;
774+
775+ const tempJsonPath = `${ tempVideoDir } /Video Title [abc123].info.json` ;
776+ fs . existsSync . mockImplementation ( ( path ) => {
777+ return path === tempJsonPath || path . includes ( '/library/__Music/Fred again' ) ;
778+ } ) ;
779+ fs . pathExists . mockResolvedValue ( false ) ;
780+
781+ await loadModule ( ) ;
782+ await settleAsync ( ) ;
783+
784+ // Verify fs.move uses the SANITIZED channel name from filesystem, not raw metadata
785+ expect ( fs . move ) . toHaveBeenCalledWith (
786+ tempVideoDir ,
787+ expect . stringContaining ( `/library/__Music/${ sanitizedChannelName } /Fred again` )
788+ ) ;
789+
790+ // Verify ensureDir was called with sanitized name
791+ expect ( fs . ensureDir ) . toHaveBeenCalledWith (
792+ expect . stringContaining ( `/library/__Music/${ sanitizedChannelName } ` )
793+ ) ;
794+
795+ // Verify _actual_filepath uses sanitized name
796+ expect ( fs . writeFileSync ) . toHaveBeenCalledWith (
797+ tempJsonPath ,
798+ expect . stringContaining ( `"_actual_filepath": "/library/__Music/${ sanitizedChannelName } /Fred again` )
799+ ) ;
800+ } ) ;
801+
802+ it ( 'uses filesystem path for channel with trailing dots (no temp downloads + subfolder)' , async ( ) => {
803+ // Scenario: Channel name is "Fred again . ." with trailing dots
804+ const channelName = 'Fred again . .' ;
805+ const videoPathInChannel = `/library/${ channelName } /Video Title [abc123]/Video Title [abc123].mp4` ;
806+ process . argv = [ 'node' , 'script' , videoPathInChannel ] ;
807+
808+ Channel . findOne . mockResolvedValue ( {
809+ sub_folder : 'Music' ,
810+ uploader : channelName
811+ } ) ;
812+
813+ fs . readFileSync . mockReturnValue ( JSON . stringify ( {
814+ id : 'abc123' ,
815+ title : 'Video Title' ,
816+ uploader : channelName ,
817+ channel_id : 'channel123'
818+ } ) ) ;
819+
820+ fs . existsSync . mockImplementation ( ( path ) => {
821+ return path === `/library/${ channelName } /Video Title [abc123]/Video Title [abc123].info.json` ||
822+ path === videoPathInChannel ;
823+ } ) ;
824+ fs . pathExists . mockResolvedValue ( false ) ;
825+ fs . readdir . mockResolvedValue ( [ 'Video Title [abc123]' ] ) ;
826+
827+ await loadModule ( ) ;
828+ await settleAsync ( ) ;
829+
830+ // Verify fs.ensureDir was called with the channel name from filesystem
831+ expect ( fs . ensureDir ) . toHaveBeenCalledWith (
832+ expect . stringContaining ( '/library/__Music' )
833+ ) ;
834+
835+ // Verify fs.move uses the actual channel name from filesystem
836+ expect ( fs . move ) . toHaveBeenCalledWith (
837+ `/library/${ channelName } ` ,
838+ `/library/__Music/${ channelName } `
839+ ) ;
840+
841+ // Verify success log
842+ expect ( logger . info ) . toHaveBeenCalledWith (
843+ expect . objectContaining ( {
844+ expectedPath : `/library/__Music/${ channelName } `
845+ } ) ,
846+ '[Post-Process] Successfully moved to subfolder'
847+ ) ;
848+ } ) ;
849+
850+ it ( 'uses filesystem path for channel with colon character' , async ( ) => {
851+ // Scenario: Channel name with : which gets sanitized by yt-dlp
852+ const sanitizedChannelName = 'Test Channel' ;
853+ const rawChannelName = 'Test: Channel' ;
854+ const tempVideoPath = `/tmp/youtarr-downloads/${ sanitizedChannelName } /Video Title [abc123]/Video Title [abc123].mp4` ;
855+ const tempVideoDir = `/tmp/youtarr-downloads/${ sanitizedChannelName } /Video Title [abc123]` ;
856+ process . argv = [ 'node' , 'script' , tempVideoPath ] ;
857+
858+ Channel . findOne . mockResolvedValue ( {
859+ sub_folder : 'Education' ,
860+ uploader : rawChannelName
861+ } ) ;
862+
863+ tempPathManager . isEnabled . mockReturnValue ( true ) ;
864+ tempPathManager . isTempPath . mockReturnValue ( true ) ;
865+
866+ fs . readFileSync . mockReturnValue ( JSON . stringify ( {
867+ id : 'abc123' ,
868+ title : 'Video Title' ,
869+ uploader : rawChannelName , // Raw name with :
870+ channel_id : 'channel123'
871+ } ) ) ;
872+
873+ const tempJsonPath = `${ tempVideoDir } /Video Title [abc123].info.json` ;
874+ fs . existsSync . mockImplementation ( ( path ) => {
875+ return path === tempJsonPath || path . includes ( '/library/__Education/Test Channel' ) ;
876+ } ) ;
877+ fs . pathExists . mockResolvedValue ( false ) ;
878+
879+ await loadModule ( ) ;
880+ await settleAsync ( ) ;
881+
882+ // Verify fs.move uses the SANITIZED channel name from filesystem
883+ expect ( fs . move ) . toHaveBeenCalledWith (
884+ tempVideoDir ,
885+ expect . stringContaining ( `/library/__Education/${ sanitizedChannelName } /Video Title` )
886+ ) ;
887+
888+ // Verify no errors logged (ensureDir should succeed)
889+ expect ( logger . error ) . not . toHaveBeenCalledWith (
890+ expect . objectContaining ( { error : expect . anything ( ) } ) ,
891+ '[Post-Process] ERROR during move operation'
892+ ) ;
893+ } ) ;
894+
895+ it ( 'handles channel name with multiple special characters' , async ( ) => {
896+ // Scenario: Channel name with multiple special chars: #<>:|?*
897+ const sanitizedChannelName = 'Channel Name' ;
898+ const rawChannelName = 'Channel #<>:|?* Name' ;
899+ const videoPath = `/library/${ sanitizedChannelName } /Video Title [abc123]/Video Title [abc123].mp4` ;
900+ process . argv = [ 'node' , 'script' , videoPath ] ;
901+
902+ fs . readFileSync . mockReturnValue ( JSON . stringify ( {
903+ id : 'abc123' ,
904+ title : 'Video Title' ,
905+ uploader : rawChannelName , // Raw name with special chars
906+ channel_id : 'channel123'
907+ } ) ) ;
908+
909+ fs . existsSync . mockImplementation ( ( path ) => {
910+ return path === `/library/${ sanitizedChannelName } /Video Title [abc123]/Video Title [abc123].info.json` ||
911+ path === videoPath ;
912+ } ) ;
913+
914+ await loadModule ( ) ;
915+ await settleAsync ( ) ;
916+
917+ // Verify basic post-processing succeeds without errors
918+ expect ( fs . moveSync ) . toHaveBeenCalledWith (
919+ expect . stringContaining ( '/library/Channel Name/Video Title [abc123]/Video Title [abc123].info.json' ) ,
920+ '/mock/jobs/info/abc123.info.json' ,
921+ { overwrite : true }
922+ ) ;
923+
924+ // Verify no critical errors
925+ expect ( process . exit ) . not . toHaveBeenCalledWith ( 1 ) ;
926+ } ) ;
927+
928+ it ( 'writes _actual_filepath with __ prefix when channel has subfolder (non-temp)' , async ( ) => {
929+ // This test verifies the fix for line 240 (missing __ prefix)
930+ const channelName = 'Test Channel' ;
931+ const videoPath = `/library/${ channelName } /Video Title [abc123]/Video Title [abc123].mp4` ;
932+ process . argv = [ 'node' , 'script' , videoPath ] ;
933+
934+ Channel . findOne . mockResolvedValue ( {
935+ sub_folder : 'Music' ,
936+ uploader : channelName
937+ } ) ;
938+
939+ fs . readFileSync . mockReturnValue ( JSON . stringify ( {
940+ id : 'abc123' ,
941+ title : 'Video Title' ,
942+ uploader : channelName ,
943+ channel_id : 'channel123'
944+ } ) ) ;
945+
946+ fs . existsSync . mockImplementation ( ( path ) => {
947+ return path === `/library/${ channelName } /Video Title [abc123]/Video Title [abc123].info.json` ||
948+ path === videoPath ;
949+ } ) ;
950+
951+ await loadModule ( ) ;
952+ await settleAsync ( ) ;
953+
954+ // Verify that _actual_filepath includes the __ prefix for subfolder
955+ expect ( fs . writeFileSync ) . toHaveBeenCalledWith (
956+ expect . stringContaining ( '/library/Test Channel/Video Title [abc123]/Video Title [abc123].info.json' ) ,
957+ expect . stringContaining ( '"_actual_filepath": "/library/__Music/Test Channel/Video Title [abc123]/Video Title [abc123].mp4"' )
958+ ) ;
959+ } ) ;
960+ } ) ;
750961} ) ;
0 commit comments