스물세번째 날: 중복된 MP3 파일 찾아서 정리하기

저자

@yuni_kim - GMT -03:00의 YUNI TZ을 가지고 있는 전산 및 조경업계의 이단아. 현재 austin의 총애를 받고 있는 유능한 시스템 운영자. 본업 이외에도 비공식적으로 Silex의 전문 스카우터로 맹활약중이다. GSLB와 GIS, Perl을 좋아하며 컴퓨터에게 일을 시키고 구경하는 일을 즐긴다. 주로 yuni라는 닉을 사용한다.

이제 일을 해볼까요?

프로젝트 마감일이 코 앞으로 다가왔습니다. 슬슬 집중해서 일을 해야하는데 오늘따라 하드디스크 한쪽에 아무렇게나 저장해 놓은 MP3 파일들이 자꾸 눈에 거슬립니다. 디렉토리를 이리저리 돌아다녀보니, 같은 파일일것 같은데 이름이 조금씩 다르거나, 이름까지 같은 파일인데 디렉토리만 다르게 해서 저장되어 있는 MP3가 많습니다. 일일이 다 들어볼 수도 없고, 정리하다 만 복잡한 디렉토리들을 다 따라다니면서 어딘가에 있을 것만 같은 중복된 파일들을 찾아보려니 무척 귀찮습니다. 그래서 중복된 MP3를 찾아주는 똘똘한 프로그램을 짜보기로 합니다.

원하는 파일 찾기

File::Find 기본 모듈은 리눅스의 find 유틸리티와 비슷한 기능을 합니다. File::Find를 이용해서 지정한 디렉토리와 그 하위의 MP3를 검색하고, CPAN의 MP3::Info 모듈로 메타 정보를 추출해 같은 정보를 가진 MP3 파일을 찾아냅니다. 같은 노래라도 MP3 파일마다 ID3 태그의 내용은 다를 수 있으므로 태그 부분은 무시하고, MP3의 Audio 부분에 대한 메타 정보(VBR, Bitrate, Frequency, Audio Data Size, 재생시간)를 사용해서 를 만들고 이것으로 같은 파일인지 아닌지 구분합니다.

t_01.pl의 전체 코드는 다음과 같습니다.

#!/bin/usr/env perl

use 5.010;
use strict;
use warnings;
use autodie;
use File::Find;
use MP3::Info;

my %mp3;
my @directories_to_search = ( shift || q{.} );
find(\&wanted, @directories_to_search);

for my $key (keys %mp3) {
    next if @{$mp3{$key}} == 1;

    say "$key =>";
    say "    $_" for @{$mp3{$key}};
    say;
}

sub wanted {
    say($File::Find::name), return if     -d;        # 디렉토리의 경우, 진행사항을 표시하고 건너뜀
    return                         if     -s == 0;   # 파일 크기가 0 인경우 건너뜀
    return                         if     /^\./;     # 파일명이 . 으로 시작하는 파일은 무시
    return                         unless -R;        # 읽기 권한이 없는 경우 건너뜀
    return                         unless /\.mp3$/i; # 파일명이 .mp3로 끝나는 파일만 검사

    my $info = get_mp3info($_);
    if (!$info) {
        warn("ERROR: $File::Find::name\n");
        return;
    }

    # MP3 정보로 간단한 Key 생성후 배열에 저장
    my $key = sprintf('%s-%s-%s-%s-%s', @$info{qw/VBR BITRATE FREQUENCY SIZE TIME/});
    push @{ $mp3{$key} }, $File::Find::name;
}

t_01.pl의 실행 결과는 다음과 같습니다.

$ perl t_01.pl 
/mp3
/mp3/copy
0-192-44.1-5676930-03:56 => 
    /mp3/03 미안해요 (feat. t.o.p).mp3
    /mp3/04 사랑하지 말아요.mp3

0-192-44.1-4547030-03:09 => 
    /mp3/82. 2NE1 - 날 따라 해봐요.mp3
    /mp3/copy/82. 2NE1 - 날 따라 해봐요.mp3

0-320-44.1-8397688-03:29 => 
    /mp3/20. 포맨 - U.mp3
    /mp3/97. 세븐 - Digital Bounce (Feat. T.O.P).mp3

0-320-44.1-8037198-03:20 => 
    /mp3/81. 나몰라패밀리 - Give Me One More.mp3
    /mp3/copy/81. 나몰라패밀리 - Give Me One More.mp3

0-320-44.1-9902498-04:07 => 
    /mp3/83. Ez-Life - 그 여자를 못 잊더라(Feat. 나오미).mp3
    /mp3/copy/83. Ez-Life - 그 여자를 못 잊더라(Feat. 나오미).mp3

진짜로 같은 노래 찾기

이런!! 다른 노래인데도 MP3 메타 정보는 같을 수가 있군요. 이제는 메타 정보에 의지하기 보단 정말 그 파일이 동일한 음악을 가진 파일인지 비교하기 위해서 오디오 스트림 부분에 대한 MD5 해시 값을 활용하기로 합니다. 다음은 이전 코드와 비교해서 달라진 부분입니다.

--- t_01.pl 2010-12-23 20:59:03.238060995 +0900
+++ t_02.pl 2010-12-23 20:58:39.488060991 +0900
@@ -6,6 +6,8 @@
 use autodie;
 use File::Find;
 use MP3::Info;
+use Digest::MD5 qw/ md5_hex /;
+use Fcntl qw/ :seek /;

 my %mp3;
 my @directories_to_search = ( shift || q{.} );
@@ -32,7 +34,23 @@
         return;
     }

-    # MP3 정보로 간단한 Key 생성후 배열에 저장
-    my $key = sprintf('%s-%s-%s-%s-%s', @$info{qw/VBR BITRATE FREQUENCY SIZE TIME/});
+    # MP3의 오디오 스트림의 MD5를 구해서 Key 생성후 배열에 저장
+    my $key = get_mp3_md5($_, $info);
+
     push @{ $mp3{$key} }, $File::Find::name;
 }
+
+sub get_mp3_md5 {
+    my $file = shift;
+    my $info = shift || get_mp3info($file);
+
+    my $data;
+
+    open my $fh, '<', $file;
+    binmode $fh;
+    seek $fh, $info->{OFFSET}, SEEK_SET;
+    read $fh, $data, $info->{SIZE};
+    close $fh;
+
+    return md5_hex($data);
+}

t_02.pl의 실행 결과는 다음과 같습니다.

$ perl t_02.pl 
/mp3
/mp3/copy
840a59d3cca332d1e73ab056cef6ead6 => 
    /mp3/81. 나몰라패밀리 - Give Me One More.mp3
    /mp3/copy/81. 나몰라패밀리 - Give Me One More.mp3

0a2ae349b995ef5b9a25f6d99a08dff7 => 
    /mp3/83. Ez-Life - 그 여자를 못 잊더라(Feat. 나오미).mp3
    /mp3/copy/83. Ez-Life - 그 여자를 못 잊더라(Feat. 나오미).mp3

c3d7951cec7f531a48ed527995d5490d => 
    /mp3/82. 2NE1 - 날 따라 해봐요.mp3
    /mp3/copy/82. 2NE1 - 날 따라 해봐요.mp3

t_02.pl의 전체 코드는 다음과 같습니다.

#!/bin/usr/env perl

use 5.010;
use strict;
use warnings;
use autodie;
use File::Find;
use MP3::Info;
use Digest::MD5 qw/ md5_hex /;
use Fcntl qw/ :seek /;

my %mp3;
my @directories_to_search = ( shift || q{.} );
find(\&wanted, @directories_to_search);

for my $key (keys %mp3) {
    next if @{$mp3{$key}} == 1;

    say "$key =>";
    say "    $_" for @{$mp3{$key}};
    say;
}

sub wanted {
    say($File::Find::name), return if     -d;        # 디렉토리의 경우, 진행사항을 표시하고 건너뜀
    return                         if     -s == 0;   # 파일 크기가 0 인경우 건너뜀
    return                         if     /^\./;     # 파일명이 . 으로 시작하는 파일은 무시
    return                         unless -R;        # 읽기 권한이 없는 경우 건너뜀
    return                         unless /\.mp3$/i; # 파일명이 .mp3로 끝나는 파일만 검사

    my $info = get_mp3info($_);
    if (!$info) {
        warn("ERROR: $File::Find::name\n");
        return;
    }

    # MP3의 오디오 스트림의 MD5를 구해서 Key 생성후 배열에 저장
    my $key = get_mp3_md5($_, $info);

    push @{ $mp3{$key} }, $File::Find::name;
}

sub get_mp3_md5 {
    my $file = shift;
    my $info = shift || get_mp3info($file);

    my $data;

    open my $fh, '<', $file;
    binmode $fh;
    seek $fh, $info->{OFFSET}, SEEK_SET;
    read $fh, $data, $info->{SIZE};
    close $fh;

    return md5_hex($data);
}

MP3 파일ID3 태그의 구조를 들여다보면, ID3v1은 MP3파일 끝부분의 128byte에 해당하며, ID3v2 이후부터는 파일의 앞부분에 위치합니다. 또한 ID3라는 같은 이름을 사용하지만 실제로 ID3v1과 ID3v2는 관련이 없습니다. 때문에 하나의 MP3 파일에 ID3v1과 ID3v2가 동시에 있을 수도 있습니다.

MP3 오디오 데이터 부분을 읽어오기 위해 MP3::Info 모듈을 이용해 알아낸 오디오 스트림의 시작 위치와 크기 정보로 seekread를 호출합니다. 이렇게 읽어온 오디오 스트림의 MD5 해시를 구하기 위해 CPAN의 Digest::MD5 모듈을 사용합니다.

MP3 메타 정보만 읽어오는 것은 파일의 아주 일부분만 읽는 것이므로 속도가 매우 빠르지만, MD5 해시를 구하려면 파일의 대부분을 읽어야하므로 상대적으로 속도가 많이 느립니다. 참고로 제 컴퓨터에서 800개의 MP3 파일의 MD5 해시를 추출해서 중복 파일 검사를 할 때 약 2분 40초 정도 소요되었습니다. 수백 개까지는 그런대로 참을만 하겠지만 수천 개 파일에 대해서 처리를 해야한다면 성능에 대한 문제도 고민해야 할 것입니다. 이 문제는 숙제로 남겨두겠습니다. :-)

파일을 지워야하는데...

같은 파일을 찾긴 했는데, 이걸 지우려니 또 막막합니다. 프로그램에서 한 파일만 남기고 지우도록 할 수도 있지만, 상황에 따라서 남기고 싶은 파일, 지우고 싶은 파일이 다르다 보니 프로그램에서 일괄적으로 지우는건 마음에 들지 않습니다. 그래서 텍스트 결과물을 보면서 파일명을 일일이 복사/붙여넣기 하며 지우기 시작했는데, 파일명에 공백이 들어간 목록을 보게되고는 그만두고 말았습니다.

마우스로 스크롤 하면서 지우고 싶은 것만 클릭해서 지울 수 있다면 얼마나 좋을까요? 드디어 GUI 프로그래밍을 해야하는 시점이 온 것일까요? Perl로 GUI 프로그램을 만들기 위해서, GTK+, wxWindow, QT, TK 등 여러가지 GUI 툴킷의 Perl 바인딩이 있지만, 해당 툴킷을 이해하지 않고서는 제대로 사용하기가 쉽지 않습니다.

그저 목록을 보여주고 사용자가 선택한 파일을 지울 수 있는 UI가 필요할 뿐인데요. 생각해보면 UI가 워낙 단순하기 때문에 웹으로도 쉽게 구현할 수 있을 것 같습니다. 그리고 단순한 기능을 구현하는 것이므로 매우 강력하고 복잡한 것(Catalyst)보다는, 적당히 강력하고 단순한 것(Dancer)을 선택했습니다. 경량 웹프레임워크인 Dancer를 사용해서 파일 목록을 전달하는 부분과 삭제하는 API를 만들고, 나머지 UI 부분은 자바스크립트와 CSS 쪽으로 넘겼습니다. Dancer를 사용하면 사실 웹서버를 따로 실행시킬 필요도 없습니다. (아... 이런것은 요즘 웹프레임워크라면 다 지원하는 기능이죠? :) 자세한 내용은 Dancer 공식 문서를 참고해주세요.

Dancer가 얼마나 사용하기 편리한지 코드부터 볼까요? Let's Dance!

--- t_02.pl 2010-12-23 20:58:39.488060991 +0900
+++ t_03.pl 2010-12-23 21:34:59.778060994 +0900
@@ -1,6 +1,7 @@
 #!/bin/usr/env perl

 use 5.010;
+use utf8;
 use strict;
 use warnings;
 use autodie;
@@ -8,8 +9,14 @@
 use MP3::Info;
 use Digest::MD5 qw/ md5_hex /;
 use Fcntl qw/ :seek /;
+use Encode qw/ encode decode decode_utf8 /;
+use Dancer;
+
+binmode STDIN,  ':utf8';
+binmode STDOUT, ':utf8';

 my %mp3;
+my $file_encode = 'utf8';
 my @directories_to_search = ( shift || q{.} );
 find(\&wanted, @directories_to_search);

@@ -37,7 +44,7 @@
     # MP3의 오디오 스트림의 MD5를 구해서 Key 생성후 배열에 저장
     my $key = get_mp3_md5($_, $info);

-    push @{ $mp3{$key} }, $File::Find::name;
+    push @{ $mp3{$key} }, decode($file_encode, $File::Find::name);
 }

 sub get_mp3_md5 {
@@ -54,3 +61,49 @@

     return md5_hex($data);
 }
+
+# ---------------------------------------------------------
+# Let's Dance
+# ---------------------------------------------------------
+
+set content_type 'application/json';
+set serializer => 'JSON';
+set charset    => 'utf-8';
+set layout     => undef;
+
+get '/' => sub {
+    content_type 'text/html';
+    send_file 'index.html';
+};
+
+get '/list' => sub {
+    return \%mp3;
+};
+
+get '/remove' => sub {
+    if (params->{file} && params->{idx}) {
+        my $file = decode_utf8(params->{file});
+        if (unlink encode($file_encode, $file)) {
+            return {
+                result => 'ok',
+                idx    => params->{idx},
+            };
+        }
+        else {
+            debug("Could not unlink $file : $!");
+            return {
+                result  => 'error',
+                message => $!,
+                idx     => params->{idx},
+            };
+        }
+    }
+    else {
+        return {
+            result  => 'error',
+            message => 'parameter is missing',
+        };
+    }
+};
+
+dance;

JSON으로 넘길 때 UTF-8을 사용해야 하는데, 사용하는 운영체제에 따라서 또는 로컬 환경 설정에 따라서 파일 이름의 인코딩이 달라질 수 있으므로 encodedecode 함수를 사용해서 파일 이름의 인코딩을 변환합니다. 윈도우즈를 사용하거나 EUC-KR 로컬을 사용하면, $file_encode의 값을 cp949euc-kr로 변경하세요.

t_03.pl의 전체 코드는 다음과 같습니다.

#!/bin/usr/env perl

use 5.010;
use utf8;
use strict;
use warnings;
use autodie;
use File::Find;
use MP3::Info;
use Digest::MD5 qw/ md5_hex /;
use Fcntl qw/ :seek /;
use Encode qw/ encode decode decode_utf8 /;
use Dancer;

binmode STDIN,  ':utf8';
binmode STDOUT, ':utf8';

my %mp3;
my $file_encode = 'utf8';
my @directories_to_search = ( shift || q{.} );
find(\&wanted, @directories_to_search);

for my $key (keys %mp3) {
    next if @{$mp3{$key}} == 1;

    say "$key =>";
    say "    $_" for @{$mp3{$key}};
    say;
}

sub wanted {
    say($File::Find::name), return if     -d;        # 디렉토리의 경우, 진행사항을 표시하고 건너뜀
    return                         if     -s == 0;   # 파일 크기가 0 인경우 건너뜀
    return                         if     /^\./;     # 파일명이 . 으로 시작하는 파일은 무시
    return                         unless -R;        # 읽기 권한이 없는 경우 건너뜀
    return                         unless /\.mp3$/i; # 파일명이 .mp3로 끝나는 파일만 검사

    my $info = get_mp3info($_);
    if (!$info) {
        warn("ERROR: $File::Find::name\n");
        return;
    }

    # MP3의 오디오 스트림의 MD5를 구해서 Key 생성후 배열에 저장
    my $key = get_mp3_md5($_, $info);

    push @{ $mp3{$key} }, decode($file_encode, $File::Find::name);
}

sub get_mp3_md5 {
    my $file = shift;
    my $info = shift || get_mp3info($file);

    my $data;

    open my $fh, '<', $file;
    binmode $fh;
    seek $fh, $info->{OFFSET}, SEEK_SET;
    read $fh, $data, $info->{SIZE};
    close $fh;

    return md5_hex($data);
}

# ---------------------------------------------------------
# Let's Dance
# ---------------------------------------------------------

set content_type 'application/json';
set serializer => 'JSON';
set charset    => 'utf-8';
set layout     => undef;

get '/' => sub {
    content_type 'text/html';
    send_file 'index.html';
};

get '/list' => sub {
    return \%mp3;
};

get '/remove' => sub {
    if (params->{file} && params->{idx}) {
        my $file = decode_utf8(params->{file});
        if (unlink encode($file_encode, $file)) {
            return {
                result => 'ok',
                idx    => params->{idx},
            };
        }
        else {
            debug("Could not unlink $file : $!");
            return {
                result  => 'error',
                message => $!,
                idx     => params->{idx},
            };
        }
    }
    else {
        return {
            result  => 'error',
            message => 'parameter is missing',
        };
    }
};

dance;

t_03.pl의 실행 결과는 다음과 같습니다.

$ perl ./t_03.pl 
/mp3
/mp3/copy
840a59d3cca332d1e73ab056cef6ead6 => 
    /mp3/81. 나몰라패밀리 - Give Me One More.mp3
    /mp3/copy/81. 나몰라패밀리 - Give Me One More.mp3

0a2ae349b995ef5b9a25f6d99a08dff7 => 
    /mp3/83. Ez-Life - 그 여자를 못 잊더라(Feat. 나오미).mp3
    /mp3/copy/83. Ez-Life - 그 여자를 못 잊더라(Feat. 나오미).mp3

c3d7951cec7f531a48ed527995d5490d => 
    /mp3/82. 2NE1 - 날 따라 해봐요.mp3
    /mp3/copy/82. 2NE1 - 날 따라 해봐요.mp3

Use of uninitialized value $setting in hash element at /Users/kunho/perl5/perlbrew/perls/perl-5.12.2/lib/site_perl/5.12.2/Dancer/Config.pm line 124.
>> Dancer server 83632 listening on http://0.0.0.0:3000
== Entering the development dance floor ...

다음 내용이 보이면 웹브라우저로 접속해봅니다.

>> Dancer server 83632 listening on http://0.0.0.0:3000
== Entering the development dance floor ...

Dancer와 함께 춤을...

와우! ;-)

클릭, 클릭, 클릭...

Perl이 다양한 자료 처리에 매우 편리하지만 모든 일을 다 Perl로 할 필요는 없습니다. Perl로 쉽게 할 수 있는 일이라면 Perl로 하고, 나머지는 다른 쪽으로 넘겨야 한다면 Dancer와 같은 경량 웹프레임워크를 활용하면 이기종간의 통신이 매우 쉬워집니다. 정작 핵심적인 중요한 부분은 금방 구현해놓고 그 외의 연동을 위한 코드를 짜기 위해 한참 동안 고민해야 한다면 너무 지루하지 않을까요? :)

덤: 슬픈 이야기

슬픈 이야기

blog comments powered by Disqus