사용자 도구

사이트 도구


project:mp3tagretrieval

Maniadb.com OpenAPI를 이용한 MP3 태그 검색기

서론

이번에는 Maniadb.com의 OpenAPI를 이용한 태그 검색기를 만들어 보겠습니다. 폴더/파일 단위로 재생하는 단순한 방식의 MP3 플레이어에서는 태그 정보가 큰 의미를 지니지 못할지도 모릅니다만, 요즘 들어서는 MP3 태그 파일은 음악 재생의 중요한 기반 정보가 되고 있습니다. 몇몇 재생기는 태그 정보가 올바르지 않으면 음악 파일을 관리하기가 매우 어려워지기도 합니다. 반면에 태그가 잘 정리되어 있으면 음악을 보다 수월하게 관리할 수 있기도 하고 음악을 들으면서 멋들어진 화면을 감상할 수 있기도 합니다.

요즘은 기술이 많이 발달해서 음악 파일의 내용을 직접 분석해 파일이 어떤 노래인지 찾아내는 서비스도 있습니다. 하지만 가끔씩 완전히 엉뚱한 결과를 내놓기도 하고, 특히 옛날 한국 가요의 정보는 잘 찾아지지 않는 때도 있습니다. 이럴 때는 결국 손으로 해결해야 합니다. 혹은 파일의 기존 태그나 몇몇 정보를 이용해 자동으로 태그를 검색하는 서비스도 있습니다. 결국 이런 서비스는 얼마나 다양한 자료를 얼마나 정확하게 보유하고 있는지가 중요합니다.

얼마 전 MP3 태그를 정리를 하려고 하였습니다. 수십 곡에 대한 정보를 찾으려니 정말 귀찮아지더군요. 90~2000년대 초중반의 한국 가요들이라 태그 정보를 찾기도 쉽지 않았습니다. 일단 자동 MP3 태그 정리 프로그램을 찾아 보니 어떤 분이 고맙게도 'TagBear'라는 프로그램을 만들어서 무료로 배포하고 계시더군요.

그런데 안타깝게도 약간 아쉬운 점이 있었습니다.

  1. 곰오디오 서버의 태그 정보도 충분히 쓸만하지만, 보다 태그가 잘(혹은 엄격히) 관리되는 곳의 정보를 원했습니다. 확인하지는 않았지만 이 곳의 태그는 유저가 직접 올린 태그 정보를 저장하고 있을 것 같더군요. 하긴 한국 가요에 데이터가 잘 관리되는 곳이 있는지도 의문입니다.
  2. TagBear가 검색하지 못한 파일에 대해 목록을 저장하는 기능이 없습니다. 결과를 유심히 체크하지 않으면 태그를 수정한 파일과 수정하지 않은 파일을 구분할 수 없습니다.

태그 DB, 특히 한국 가요에 대한 태그 정보 저장소를 찾던 중 Maniadb란 곳을 알았습니다. 이 곳의 정보는 나름대로 일괄적인 관리가 이루어지고 있는 듯 하더군요. 그리고 OpenAPI를 제공하여 사용자가 손쉽게 이곳의 자료를 활용할 수 있게 만든 것도 맘에 들었습니다. 그래서 이번에는 Maniadb의 자료를 이용해 파이썬 기반으로 MP3 파일의 태그를 수정하는 간단한 프로그램을 작성하기로 하였습니다. 사실 이런 장난감을 만드는 일은 어쩌면 배보다 배꼽이 더 큰 격이지만, 그래도 해 보겠습니다. 그냥 재미로 하는 거니까요.

추가) 윈앰프에서는 GraceNote를 사용하고, 이스트소프트의 알송도 있습니다.

프로그램의 목적과 방향

MP3 태그를 전문적으로 수정할 수 있는 프로그램은 많이 있습니다. 대표적으로 'Mp3tag'가 있지요. 오랫동안 충분히 검증되어 온 좋은 프로그램입니다. 아마 태그 정리를 해 보신 분들은 이 프로그램에 대해 한번쯤은 다 들어보셨으리라 생각합니다.

제가 하고 싶은 것은 MP3 태그에 대해 한 번쯤 프로그램적으로 접근해보고 싶은 것과 다른 전문적인 태그 편집 프로그램들을 위하여 태그의 초기 정보를 쉽게 검색할 수 있는 수단을 제공하는 것입니다. 우리의 파이썬 장난감 프로그램은 감히 그러한 프로그램들과는 감히 견줄 수 없습니다. 어려운 작업들은 배제하고 원격 서버로부터 있는 태그 정보만 검색해서 저장하는 기능만 구현할 것입니다.

또한 MP3의 너무 복잡한 내용의 태그는 건드리지 않고, 가급적 간단한 부분의 것만 수정하도록 할 생각입니다. 우리가 사용하게 될 ID3V2 태그는 상당히 구체적으로 태그를 정의했습니다. 별의별 내용들을 다 적을 수 있습니다. 하지만 우리는 Maniadb가 제공하는 정보 이상의 것을 기록하지는 않을 것입니다.

또한 프로그램은 CLI (Command Line Interface) 형태로 제작될 것입니다. GUI가 사용하기 편리하긴 하지만 제작하기 상당히 까다롭습니다. 그리고 프로토타입으로 빠르게 만들어 본 CLI 프로그램을 써서 약 60곡 정도의 태그를 검색해본 결과 익숙해지면 나름 편리(!)했습니다.

우리가 만든 프로그램은 우리만의 장난감으로 쓰는게 맞겠지만, 가능하다면 이런 것도 생각해 보겠습니다.

  1. 파이썬 스크립트를 바이너리 형태로 만들기?
  2. 파이썬이 설치되지 않은 PC에 우리 장난감이 돌아갈 수 있게 한다면?

여담이지만, 어떤 방식을 쓰든 태그 정리 작업은 골치아프고 힘든 노동이라 생각합니다. 인내가 필요합니다. m(

기반지식

웹페이지를 통해 내 PC의 프로그램 상태 모니터링하기 문서에서 이미 네트워크를 통해 서버와 클라이언트가 어떻게 통신을 하는지에 대해 간략히 서술하였습니다. 우리가 보는 '웹페이지'란 것은 서버에서 클라이언트로 전송된 HTML 문서 형식을 읽어들여 웹브라우저가 이것을 화면에 표시하는 것이었습니다. 이번에는 HTML과 유사한 'XML'이라는 형태의 문서를 다뤄 보도록 하겠습니다. Maniadb는 XML 기반으로 데이터를 전송하거든요.

MP3 태그 수정은 파이썬 기반의 잘 만들어진 라이브러리를 사용할 것입니다. 그렇지만 그것은 우리가 짧은 시간에 그만한 성과를 낼 수 없기 때문에 사용하는 것이지 MP3 태그 내용에 대해 신경쓰고 싶지 않다는 뜻은 아닙니다. 통달은 할 수 없지만 MP3 파일 내부에 대체 어떻게 그런 태그 정보들이 저장되는지에서는 한 번 확실하게 감을 잡을 필요가 있습니다. 그러므로 MP3 태그 포맷의 하나인 'ID3V2'에 대해서도 알아보고 가겠습니다.

OpenAPI

'API'라는 말 들어보신 적 있으신가요? 'Application Programming Interface'의 약자로 프로그래밍을 하다 보면 많이 언급되는 단어입니다. 간단히 말해서 그 프로그램(혹은 언어)이 제공하는 기능을 활용하기 위한 인터페이스입니다.

사실 인터페이스란 말에 대해 딱 정의를 내리고 '이것이다!'라고 하기에는 본 문서도 너무 미흡할 뿐더러 그렇게 하기도 어렵네요. 쉽게 비유적으로만 간단히 설명을 드리는 쪽으로 하겠습니다. “프로그램의 인터페이스가 훌륭하다.”라는 말을 많이 들어보셨을 겁니다. 프로그램이 '사용하기 무척 편리하게 디자인되었다'라는 뜻이죠. 흔히 말하는 '훌륭한 인터페이스를 가진 프로그램' 이라는 말은 보통 '프로그램의 그래픽 인터페이스가 좋다'는 말과 거의 유사합니다. 시각적인 요소도 좋고, 편리하게 동작한다는 뜻입니다. 사실 인터페이스란 말은 눈에 보이는 것들을 말하는 것도 하지만 눈에 보이지 않는 개념적인 것들에게도 사용할 수 있는 말입니다.

다른 예로 DVD 플레이어를 들어볼까요? DVD 플레이어로 DVD 영화를 보기 위해서는 어떻게 할까요? 네, DVD를 넣고, 재생 버튼을 누르면 됩니다. 어린 아이도 간단히 할 수 있죠. DVD로 영화를 보는 법은 이처럼 간단합니다. 그러나 DVD가 어떻게 영화를 재생할 수 있는지 그 원리에 대해 대충이라도 올바르게 설명할 수 있는 분은 그다지 많지 않으리라 생각합니다. 아무튼 상당히 많은 것을 상세히 알고 있어야 합니다. 하드웨어적으로는 광학 장치나 전자기기 지식이 있어야 할 것이고, 소프트웨어적으로는 영상 처리 및 영상 코덱의 원리에 대해서도 알아야 할 겁니다. DVD 플레이어는 일견 간단해 보였지만 그렇게 간단한 물건이 아닌 듯 합니다. 하지만 어떻게 그런 복잡한 기계를 다룰 수 있을까요? DVD 플레이어 원리는 어려워도 이 질문은 간단합니다. '인터페이스'가 단순하게 되어있으니까요. 디스크 넣기/빼기, 재생, 정지. 단추 몇 개만 알면 사용할 수 있습니다.

이제 인터페이스에 대해 대략적으로 감을 잡으셨으리라 생각합니다. 그럼 프로그래밍에서의 인터페이스, 즉 API는 어떨까요? API는 보통 클래스나 함수의 목록으로 되어 있습니다. 이 API를 설명한 문서들을 읽어 보면 대개 API에 필요한 입력/출력 및 동작에 대해 자세한 내용을 명시하곤 합니다. 대표적으로 Microsoft의 Windows API가 있죠. 이 API는 윈도우를 조작하기 위해 Microsoft에서 공개한 기능들의 집합을 말합니다. 우리가 Windows에서 일어나는 모든 일들을 제어하거나 관리하기 위해서는 이 API를 사용해야만 합니다. 아마 다른 방법은 없을 겁니다. 다른 방법이 있다라고 해도 그 방법을 조금만 파헤쳐보면 결국 그 또한 Windows API를 기반으로 한 껍데기일 뿐일 것입니다.

사실 우리가 탐색기에서 파일을 하나 생성하고, 복사하고, 지우는 등의 작업들을 할 수 있는 것은 다 그러한 인터페이스(API)가 있기 때문에 가능한 것입니다. 우리 눈에 직접적으로 보이지는 않지만 운영체제는 파일을 생성/복사/삭제하는 방법이 명시되어 있고, 그것을 지켜 프로그램을 하면 그대로 동작하도록 되어 있습니다. 우리가 마우스와 키보드를 이용해서 여러 작업을 할 수 있는 것도 마우스와 키보드에 관한 API가 있으니까 가능한 것입니다.

그렇게 놓고 보면 '모든 것이 인터페이스인가?'라는 생각이 드네요. 그렇지만 모든 것이 인터페이스는 아닐 것입니다. 인터페이스는 서로 다른 성질의 것들이 서로 상호작용을 하기 위해 입/출력을 주고 받을 수 있는 수단을 말하는 것이지 그 서로 다른 성질이 어떤 것임에는 설명해주지 않기 때문이지요. 우리가 Windows API를 사용하면서 신경써야 할 것은 그것의 입력/출력이 올바른지입니다. 그것이 어떻게 동작하지에 대해서는 우리는 알 방법이 없습니다. 프로그램 서적에서 흔히 말하는대로 '알 필요도 없고, 알아서도 안되는' 그런 것입니다. 그냥 우리는 그 API가 입력이 올바르면 올바르게 동작해서 올바른 결과가 나온다는 것만 알면 됩니다.

이것은 DVD가 고장나서 서비스 센터에 가져갔을 때로 비유할 수 있겠습니다. 기사님이 고장난 DVD 플레이어를 어찌어찌 만지니 우리가 모르던 어떤 화면들이 나와서 기기를 진단합니다. 우리는 처음 보는 화면이고 무슨 말인지 모르겠으나 그 분은 그것으로 문제를 판단해내지요. 허나 우리는 그런 진단 화면이 나오든 말든지, 그 고장이 어떤 이유이고 어떻게 해야 고쳐지는지 자세히 알 필요는 없습니다. 우리는 DVD 플레이어가 고장이 났으니 서비스 센터에 왔고, 신속하고 정확하게 잘 고쳐지기만 하면 됩니다. 괜히 귀찮게 이것저것 기웃기웃댈 필요가 없죠. 무리해서 알아내려 하다 잘못하면 '기업비밀'을 건드릴 수도 있습니다. 큰일 날 수도 있죠? ;-)

'OpenAPI'란 어떤 서비스, 시스템을 다른 곳에서 제어할 수 있도록 API의 일부를 외부로 노출시켰다는 말입니다. 최근 많은 업체들이 서비스의 일부 기능들을 OpenAPI로 공개하고 있습니다. 말씀드렸듯, Maniadb 또한 MP3 검색 기능을 OpenAPI로 공개하였습니다. 왜 이렇게 자신들의 서비스를 공개하느냐구요? 그것은 서로 '윈-윈(Win-Win)'을 기대하기 때문이겠지요.

API를 사용하는 쪽은 제공되는 서비스를 활용하여 자신들의 창의성을 발휘하면 됩니다. 서비스 유지보수나 개선에 대해 큰 신경쓰지 않아도 됩니다. 아주 큰 수고를 덜 수 있습니다. API를 공개하는 쪽은 쉽게 서비스 홍보(혹은 저변확대)가 됩니다. 가끔은 API를 만드는 이들이 미처 생각하지 못한 매우 참신하고 창의적인 방법으로 서비스가 이용됩니다. 그러면 쓰는 이와 쓰도록 하는 이 모두에게 큰 이득이 되는 셈입니다.

만일 Maniadb가 검색 기능을 OpenAPI로 공개하지 않았다면, 노래에 대한 정보 검색은 무조건 Maniadb 홈페이지를 통해서만 가능할 겁니다. 그렇게 닫힌 상태로 있다면 사용하는데 제한도 커집니다. 그랬다면 우리의 장난감은 탄생할 수 없었을 겁니다. 물론 우리가 그렇게 Maniadb에 기여를 할 수는 없겠지만, 뭐 일단 살짝 묻어는 가 보는 겁니다.

XML/DOM

XML

XML과 DOM에 대해서도 이야기를 시작하면 끝도 없이 나올 수 있습니다. XML 또한 깊게 들어가면 책 몇권으로도 모자랍니다. 우리가 OpenAPI를 이용했을 때 전달되는 XML 메시지만 간략히 이해할 수 있을 정도로만 설명을 하고 마치겠습니다.

XML은 정보를 표현하기 위한 한 방법입니다. 예를 들어 비틀즈의 'Hey Jude'란 노래에 대해 설명을 하고자 합니다. 그러면 Hey Jude에 대해 이런 설명을 준비할 수 있을 겁니다.

== 노래 소개 ==
제목: Hey Jude
가수: the Beatles
장르: pop
발표: 1968년
가사: Hey Jude, Hey jude, don't make it bad. Take a sad song and make it better...
==============

같은 내용을 XML 문서로 표현해 보겠습니다.

<?xml version="1.0"?>
<taginfo:song>
    <title>Hey Jude</title>
    <artist link='http://www.thebeatles.com/'>Beatles, the</artist>
    <genre>Pop</genre>
    <release year='1968'/>
    <lyrics>Hey Jude, Hey jude, don't make it bad. Take a sad song and make it better...</lyrics>
</taginfo:song>

아주 간단히 표현해 보았습니다. HTML 코드를 몇 번 보신 분들은 그리 낯설지 않을 것입니다. 사실 둘은 사촌지간 격입니다. XML은 일반적인 정보를 표현하는 방법인 반면, HTML은 웹페이지 문서를 표현하는 방법입니다. XML이 보다 일반적인 방법이므로 HTML은 XML의 부분집합일 겁니다. XML방식으로 HTML을 기술한 것을 'XHTML'이라고 합니다. 이에 관해서는 따로 검색을 해 주세요.

XML 문서는 반드시 다음과 같이 시작합니다.

<?xml version="1.0"?>

그리고 여는 태그의 시작인 '<' 괄호와 닫는 태그의 끝인 '>' 괄호 사이의 문자열을 가리켜 '엘리먼트(element)'라고 합니다. 그러므로 'title' 엘리먼트는 '<title>Hey Jude</title>' 네요.

'<title>'과 '</title>'은 '태그(tag)'라고 부릅니다. 이 정보가 무엇인지 정의하기 위한 용도입니다. 태그 이름은 몇 가지 규칙만 지키면 자유롭게 지을 수 있지만 그래도 내용들과 관계 깊은 이름으로 짓는 것이 좋겠지요. 태그는 열었으면 반드시 닫아야 합니다. 만일 태그 사이에 내용이 없다면 'release' 태그처럼 줄여서 '<release />' 열고 닫는 태그를 한번에 쓰는 것도 가능합니다.

release에 있는 year='1968'은 '어트리뷰트(attribute)'라고 부릅니다. 보통 태그가 전달하고자 하는 정보를 보다 자세히 설명하는데 사용합니다. '필드=값' 형태로 구성되어 있습니다.

XML 문서는 구조적입니다. 위 예에서 보이듯 최상위 엘리먼트로 'song'이 위치하고 'song'의 하위 엘리먼트로 'title', 'artist', 'genre', 'release', 'lyrics'가 있습니다. 다른 엘리먼트는 관계없지만 최상위 엘리먼트는 반드시 단 1개만이 존재해야 합니다. 최상위의 위치에 2개 이상의 엘리먼트가 나와서는 안 됩니다.

'song' 태그 앞에 붙은 'taginfo'를 가리켜 '네임스페이스(namespace)'라고 부릅니다. 네임스페이스는 태그의 중복을 방지하기 위해 있습니다. 이는 제시하고자 하는 태그의 의미를 명확히 제시하기 위한 수단이기도 합니다. 예를 들어, '노래(song)'란 단어 하나로는 표현하고자 하는 정보가 너무 광범위합니다. 위 XML문서는 '태그 정보'임을 말하기 위해 'taginfo'라는 네임스페이스를 선언했습니다. 이렇게 하면 뜻도 보다 명학해지며, 우리 XML 문서가 아닌 다른 이가 작성한 XML 문서에서도 'song'이라는 태그를 그 나름대로 활용할 수 있게 됩니다.

XML은 보다시피 사람이 보아도 이해가 되는 문서입니다. 문서의 구조는 엄격한 규칙을 갖고 있으므로 기계적으로도 해석하기 용이합니다. 기계가 XML 문서를 이해하기 위해서는 XML 문서 '파싱(parsing)'이라는 작업을 해야 하며, 이 일을 하는 장치(소프트웨어)를 가리켜 XML '파서(parser)'라고 합니다.

DOM

DOM (Document Object Model)은 XML 문서 접근 및 조작을 위한 방법입니다. 앞서 XML은 구조적인 형태로 되어 있다고 하였습니다. 보통 컴퓨터 프로그래밍에서 '구조적인 형태'라 함은 대개 '트리(tree)'로 표현 가능하다는 말과 거의 동일합니다. DOM은 XML 문서를 하나의 트리로 해석합니다.

트리로 표현한다는 말은 조직도를 그린다는 것과 상당히 유사합니다. XML을 소개하면서 예를 들었던 문서를 가지고 한 번 '조직도'를 그려 보도록 하지요. 앞에 애스트리크(*)가 붙은 것은 어트리뷰트입니다.

taginfo:song
|
|------title
|           \textnode
|
|------artist
|            \*link
|            \textnode
|------genre
|           \textnode
|
|------release
|             \*year
|
|------lyrics
             \textnode

XML은 XML 파서에 의해 기계가 이해할 수 있다고 하였습니다. 다시말해 DOM은 XML이 파싱된 결과라고 볼 수 있습니다. DOM을 이용하면 XML 내부의 각 구조에 쉽게 접근할 수도 있고, 손쉽게 수정할 수도 있습니다. 당연히 다시 XML 문서로 재발행할 수도 있습니다.

트리에서 taginfo:song, title, artist, textnode 같은 부분을 가리켜 '노드(node)'라고 합니다. taginfo:song 노드는 5개의 자식 노드를 가지고 있습니다. 5개의 노드는 트리의 같은 높이에 있기 때문에 서로 형제(sibling) 노드입니다. 이 5개의 노드는 같은 부모(parent)노드인 taginfo:song를 가지고 있습니다.

엘리먼트 안의 시작 태그와 종료 태그 사이에 있는 텍스트는 textnode에 있습니다. 예를 들어, 'title' 태그 사이에는 'Hey, Jude'라는 값이 있습니다. title 노드의 자식으로 textnode가 있고, 'Hey Jude' 문자열이 바로 textnode입니다.

이렇게 DOM을 이용하면 잘 조직된 체계(트리)에서 쉽게 원하는 정보를 조회, 수정 및 삽입할 수 있습니다. 우리도 Maniadb가 보내온 XML을 DOM Tree로 만들어 정보를 추출할 것입니다.

ID3 Tag

MP3 태그 포맷으로 ID3가 있습니다. ID3는 두가지 버전이 있습니다. 하나는 ID3v1, 다른 하나는 ID3v2입니다. v1과 v2는 사실상 이름만 비슷할 뿐 전혀 관련이 없으며 ID3v1은 태그 기록에 제약이 많아 현재는 ID3v2를 많이 사용하고 있습니다. ID3v2는 현재 ID3v2.4까지 나와 있으나 아직은 ID3v2.3을 많이 사용하고 있습니다.

ID3v2.3의 태그 구조

헤더

ID3v1 태그가 파일의 뒷부분에 위치한 것과는 달리, ID3v2.3 태그(이하 태그)는 MP3 파일의 처음에 위치합니다. 태그는 'ID3'라는 문자열로 시작합니다. ID3 다음의 1바이트는 major version, 다음 1바이트는 revision version입니다. ID3v2.3은 ID3v2.3의 major verion은 3, revision version은 0입니다.

다음 1바이트는 플래그를 위한 바이트입니다. 상위 3비트만 플래그로 사용합니다. 상위부터 하위로 'unsynchronization', 'extended header', 'experimental header'를 의미합니다.

  • unsynchronization: synchronization에 관한 플래그입니다. 정확한 의미는 파악하기 여러우나 크게 신경 쓸 필요가 없는 부분 같습니다.
  • entended header: 확장 헤더를 사용할지에 대한 플래그. 1이면 사용합니다.
  • experimental header: 실험적인, 아직 테스트 중인 헤더 내용을 사용할지에 대한 플래그. 1이면 사용합니다.

다음 4바이트는 태그의 전체 사이즈를 가리깁니다. 태그 사이즈는 헤더 자체는 제외한 사이즈를 가리킵니다. 단, 확장 헤더는 포함합니다. 그런데 태그 사이즈를 가리키는 4바이트는 4바이트를 전부 사용하는 것이 아니라, 각 바이트의 상위 1비트는 제외하고 계산해야 합니다. 다음의 예에서 자세히 설명하도록 하겠습니다.

헤더의 예

파일의 처음부터 다음과 같은 16진수가 나열되어 있다고 합니다.

49 44 33 03 00 00 00 02 2A 52 
16진수 값 의미
49 44 33'ID3'의 문자열을 의미합니다.
03 00 major version 3, revision version 0
00 어떤 플래그도 없습니다.
00 02 2A 52 각 바이트로부터 상위 1비트를 제외한 7비트만을 가져와 총 28비트의 2진수로 만듭니다. 이 2진수가 헤더의 사이즈입니다.
00 02 2A 52는 2진수로 '0000 0010 0010 1010 0101 0010'입니다. 여기서 각 바이트의 상위 1비트를 제거해 28비트로 만들면 '000 0010 010 1010 101 0010'이 됩니다. 이는 10진수로 38226입니다. 2A 52 다음부터 38226바이트의 문자는 ID3v2태그입니다.
프레임

헤더 다음부터는 프레임이라는 정보가 나옵니다. 프레임은 태그의 필드(곡명, 가수, 장르 등)에 해당합니다. 프레임 헤더는 10바이트로 되어 있습니다. 프레임 헤더의 처음 4바이트는 프레임의 ID로서, 미리 정해져 있는 4개의 문자열로 이루어져 있습니다. 그다음의 4바이트는 프레임의 사이즈를 말합니다. 프레임 헤더를 제외한 프레임의 크기입니다.

마지막의 2바이트는 플래그입니다. 각 바이트의 상위 3바이트를 사용하는데 첫번째 바이트는 상태 메시지와 관련이 있고 두번째 바이트는 태그 자체가 인코딩된 방법과 관계가 있습니다. 자세한 사항은 공식 문서를 읽어 보세요. 이 다음부터는 프레임의 내용이 시작되는데, 프레임의 ID 종류에 따라 조금씩 형식이 다르므로 자세한 사항은 역시 문서를 참고해야 합니다.

프레임의 예
41 50 49 43 00 00 89 26 00 00 00 69 6D 61 67 65 2F 6A 70 65 67 00 03 00 FF D8 ...
16진수 값 의미
41 50 49 43 문자열 'APIC(Attached Picture)'입니다. 이 프레임은 사진 정보가 담겨 있음을 의미합니다.
00 00 89 26 0x8926, 즉 프레임의 내용은 모두 35110 바이트를 차지합니다. 헤더 크기는 제외되어 있습니다.
00 00 플래그는 모두 초기화되어 있습니다.
00 문자 인코딩은 ISO-8859-1을 따른다.
69 6D 61 67 65 2F 6A 70 65 67 00 여기부터는 프레임의 내용이 시작됩니다. 각 프레임의 종류마다 각각 다른 포맷으로 구성되어 있습니다. APIC에서 이 위치에 오는 데이터는 널로 끝나는 문자열로, 값은 'image/jpeg'입니다. 이는 그림의 MIME 타입을 말하는 문자열입니다. 널로 끝나는 문자열이므로 반드시 '0x00'으로 끝납니다.
03 이것은 이 그림이 전면 커버 그림이란 것을 의미합니다. 그림은 종류에 따라 0x00~0x0E, 총 15가지로 분류되어 있습니다.
00 이 위치에는 그림에 대한 설명을 넣을 수 있으나, 현재 비어 있습니다. 64글자까지 가능합니다.
FF D8 이미지 정보의 시작. 실제로 JPEG 포맷 파일은 'FF D8'이라는 값으로 시작합니다. 헤더의 끝인 '00 00 89 26' 이후인 '00 00 00 69 6D 61'부터 총 35110 바이트를 읽어들이고, 정확한 이미지의 시작 부분인 FF D8부터 읽어들인 내용을 파일로 저장하면 그 그대로 이미지 파일이 됩니다. 이미지 파일의 종류는 MIME 타입에서 이미 정의하였습니다. 만일 이 그림을 따로 저장하려 한다면, 포맷은 'JPEG'이므로 '.jpg', 혹은 '.jpeg' 파일로 저장하면 될 것입니다.

이 다음의 프레임 값은 다음과 같이 되어 있습니다.

54 41 4C 42 00 00 00 2B   00 00 01 FF FE 53 00 65
00 6F 00 74 00 61 00 69   00 6A 00 69 00 20 00 61
00 6E 00 64 00 20 00 42   00 6F 00 79 00 73 00 20
00 49 00 49 00  
16진수 값 의미
54 41 4C 42 TALB. 'Original album/movie/show title'으로, 이 프레임은 앨범 제목을 담고 있습니다.
00 00 00 2B 프레임 내용의 크기는 43바이트입니다.
00 00 플래그가 없습니다.
01 Unicode를 사용하였습니다.
FF FE … 여기서부터 문자열이 시작됩니다. 'FF FE'는 BOM(Byte Order Mark). 리틀-엔디안 방식의 'UTF-16' 인코딩을 의미합니다.이 문자열을 실제로 읽어들이면 'Seotaiji and Boys II'가 됩니다. 널 문자로 끝나지 않음에 유의해야 합니다.

마지막으로, 노래 제목을 뜻하는 TIT2 프레임을 읽어 봅니다.

54 49 54 32 00 00 00 13  00 00 01 FF FE 58 D5 EC
C5 00 AC 28 00 55 4F 82  59 4C 6B 29 00 
16진수 값 의미
54 49 54 32 TIT2. The 'Title/Songname/Content description' 곡명을 의미하는 프레임입니다.
00 00 00 13 19바이트가 기록되어 있습니다.
01 Unicode를 사용함
FF FE .. 문자열의 시작 유니코드로 '하여가(何如歌)'가 나온다.

태그의 구성에 대해서는 어느 정도 감을 잡으셨으리라 생각합니다. 프레임이 워낙 많고 자세하게 나누어져 있어 모두 설명드리기 어렵습니다. 이 정도 설명을 이해했다면 D3v2.3.0 Informal standard도 이해하는 것은 크게 어렵지 않을 것입니다. 한 번 HEX editor를 이용해 MP3파일을 직접 열어보는 것도 좋은 실습이 될 것입니다.

Encoding

'인코딩(encoding)'은 어쩌면 컴퓨터로 정보를 전달하는데 있어 가장 근본적이고 중요한 문제라고 할 수 있습니다. 이것이 엉망이면 사실상 어떤 정보도 제대로 전달되고 표현되지 못하니까요. 가끔씩 흔히 '글자가 깨졌다'라고 표현하기도 하는 다음 상황은 다들 한번쯤 겪어본 적이 있을 것입니다.

  • 웹페이지 혹은 이메일이 이상한 문자들로 표현된다.
  • MP3 태그의 글씨가 이상한 문자들로 보인다.
  • 동영상 파일의 자막이 이상한 문자들로 보인다.

이 모든 일들의 원인은 바로 '인코딩의 불일치'입니다.

비트와 바이트

일단 맨 처음으로 돌아가서 컴퓨터가 정보를 저장하는 방식에 대해 논해 보도록 하겠습니다. 컴퓨터는 디지털, 즉 2진수만 취급한다는 사실은 상식적으로 다 알고 계시리라 생각합니다. 2진수 체계에서 표현할 수 있는 상태는 0과 1 단 두 가지 상태 뿐입니다. 0이냐 1이냐, 다른 말로 '참'이냐 '거짓'이냐 이 두 상태밖에는 없는 겁니다.

컴퓨터는 사실 0 혹은 1의 전기 시그널만 캐치할 뿐이며 다른 어떤 일도 하지 못하는 깡통입니다. 대신 그 단순한 일은 기막히게 빠른 처리가 가능하지요. 그리고 단순한 두 가지 상태 표현은 자릿수를 늘려버리는 방식으로 극복합니다. 0과 1을 무지막지하게 늘어 놓는 겁니다. 0 혹은 1을 하나 표현할 수 있는 한 자릿수를 일컬어 '비트(bit, binary digit)'라고 합니다. 8비트는 1'바이트(byte)'가 됩니다(보통 비트는 정보 저장의 기본 단위, 바이트는 정보 표현의 기본 단위라고 합니다).

1바이트만 해도 2^8=256. 256개의 상태를 가지고 있습니다. 이것을 단순히 숫자로 해석할 수도 있었지만, 숫자 이외의 문자 기호를 컴퓨터에서 표시하기 위해 사람들은 숫자와 문자를 대응시켰습니다. 컴퓨터가 문자를 입력받고 문자를 잘 출력한다고 해서 문자를 위해 별다른 체계를 이용하는 것은 아닙니다. 그냥 여전히 묵묵히 숫자 0/1만 입력받고 출력받되, 우리 눈에 글자로 보이도록 전시 형태만 맞춰 주는 겁니다.

문자 인코딩과 디코딩

이 문자/숫자 대응 규칙 중 대표적인 것이 바로 'ASCII 코드'입니다. (원래 ASCII 코드는 7비트 체계였습니다. 나중에 8비트로 확장되었습니다.) ASCII 코드는 눈에 보이지 않는 제어 기호(엔터, 탭, 백스페이스 등)외에 영문자, 숫자, 특수문자를 0~127까지 숫자에 대응시킨 것입니다. 여기서 중요한 점은 우리 눈에 최종적으로 '글자'라고 보이는 것들의 실체는 사실 '숫자'라는 사실입니다. 다시 말해 어떤 숫자가 어떤 글자에 대응되어 있는지에 대한 '지도(표)'가 존재하고, 그 규칙에 따라 숫자가 글자로, 글자가 숫자로 대응되고 있다는 사실입니다.

글자를 숫자로 대응시키는 것을 '문자 인코딩', 숫자를 글자로 대응시키는 것을 '문자 디코딩'이라고 합니다. 인코딩과 디코딩 때 같은 규칙에 기반해 올바르게 대응시키면 아무 문제가 없습니다. ASCII 코드 규칙 하에 'a'는 0x61입니다. 역으로 0x61이란 숫자는 0x61이란 숫자로 볼 수도 있지만, ASCII 코드로 보자면 'a'입니다.

그러나 'a↔0x61' 이란 변환 규칙이 잘못된다면? 만일 ASCII 코드가 아닌 다른 문자 표현 규칙이 있다고 가정하지요. 우리는 'a'를 ASCII 문자표를 이용해서 전송했는데, 받는 쪽에서 이를 ASCII 코드 표가 아닌 그 다른 문자 표현 규칙을 들이대서 해석하려고 한다고 칩시다. 그러면 우리가 'a'로 의도하고 보낸 0x61은 분명히 다른 문자로 해석될 겁니다. 규칙 자체가 어그러졌으니 원래 내용이 아닌 전혀 엉뚱한 글자들이 화면에 가득찰 것입니다. 이 현상을 일컬어 '글자가 깨졌다!'라고 표현하는 것입니다. 아, 물론 데이터 자체가 손상되었을 수도 있지만요.

코드페이지(codepage)

컴퓨터 상에서 문자를 표현하는 규칙을 적은 표는 여러가지가 있습니다. 각국의 언어 환경에 따라서도 달라질 것이고 언어를 어떻게 표기하느냐에 따라서도 달라질 것입니다. '조합형 한글'이니 '완성형 한글'이란 것은 바로 이 규칙 중의 하나입니다. 이런 규칙을 코드페이지(codepage)라고 합니다. 최근에는 '유니코드(unicode)'를 많이 사용합니다. 중구난방으로 사용되던 각국의 문자 표현 방식을 표준화한 것이 유니코드입니다. 그 중 일반적으로 'UTF-8'이란 인코딩 방법이 많이 사용됩니다. 최근 대부분의 리눅스 시스템이나 인터넷 주소는 대부분 UTF-8 인코딩을 사용합니다. 한글 마이크로소프트 윈도우도 UTF-8을 지원하지만, 기본적으로는 한글을 표현하기 위해 'CP949'라고 하는 인코딩 규칙을 사용합니다. 보통 한글 웹페이지에서 잘 발견되는 'EUC-KR' 문자 인코딩은 'CP949' 문자 인코딩 규칙과 매우 유사합니다. 명령 프롬프트에서 코드 페이지를 확인하는 방법은 다음과 같습니다.

chcp

코드 페이지를 cp949에서 UTF-8로 변경하려면 다음과 같이 하면 됩니다.

chcp 65001

다시 cp949로 변경하려면 다음과 같이 하면 됩니다.

chcp 949

이제 문자가 깨지는 현상에 대해서 어느 정도 자세한 설명이 가능할 것 같습니다.

웹페이지, 이메일의 한글 깨짐

대개 웹페이지나 이메일에서 문자가 올바르게 표현되지 않는 현상은 그 문서의 내용, 대개 HTML 코드(이메일에서도 HTML 코드를 쓰는 경우도 있습니다)에서 어떤 인코딩을 사용하는지에 대해 지시하지 않아서 그렇습니다. 보통 웹브라우저는 기본적으로 UTF-8을 이용하는데, 웹페이지는 내용을 저장하기를 'EUC-KR' 인코딩으로 하였습니다. 그리곤 인코딩 상태에 대한 아무런 힌트도 주지 않습니다. 이렇게 힌트를 주지 않으면 웹브라우저는 전송받은 문자를 어떤 방식으로 문자 디코딩을 해야 할지 알 수 없습니다. 그러면 문자가 깨집니다. 보통 HTML 문서 초반에는 다음과 같이 문서가 저장된 인코딩 방식에 대해 표시하도록 권장합니다.

<head>
<meta http-equiv="Content-Type" content="text/html; charset=euc-kr">
...
</head> 

이렇게 해야 웹브라우저는 문서가 euc-kr로 인코딩된 문서임을 알아채고 올바르게 문자 디코딩을 euc-kr 체계에 맞춰 합니다.

ID3 태그의 한글 깨짐

ID3V2.3의 문서를 보면 인코딩은 ISO-8859-1/UNICODE 두 가지로만 되어 있습니다. 여기에 함부로 CP949 등의 인코딩으로 문자를 적으면 태그의 글자가 올바르게 표시되지 않습니다. 어떤 프로그램에서는 태그가 깨지는데 어떤 프로그램에서는 태그가 안 깨지는 경우는요? 안 깨지게 읽어들이는 프로그램이 미리 알아채서 대비한 것일 뿐입니다.

자막의 한글 깨짐

마지막으로 동영상의 한글 자막은 이런 이유로 제대로 표시되지 않습니다. 한글 자막은 거의 EUC-KR(CP949) 인코딩으로 저장됩니다. 그러나 리눅스의 동영상 프로그램들이나, 최근 동영상 플레이가 가능한 TV 일부, 특히 해외에서 구입한 TV는 자막 파일을 UTF-8 문자 인코딩을 기본으로 사용합니다. 이럴 때 자막 파일이 EUC-KR로 되어 있으면 한글이 제대로 나오지 않을 수 있습니다. 자막 파일의 문자 인코딩을 변경하거나 동영상 프로그램의 기본 문자 디코딩 방식을 바꾸어 주면 해결됩니다. 텍스트 편집기에서 인코딩을 변경하는 것은 어렵지 않습니다. 똑똑한 텍스트 편집기의 경우 인코딩을 자동으로 감지하는 기능도 가지고 있습니다(심지어 윈도우 기본 프로그램인 메모장도 됩니다). 그러나 수동으로 지정해야만 하는 경우도 간혹 있습니다.

BOM (Byte Order Mark)

유니코드에서 인코딩을 지정하다 보면 BOM(Byte Order Mark)라는 말을 만날 때가 있습니다. 이것은 유니코드에서 자신이 어떤 인코딩을, 어떤 엔디안을 쓰는지를 지정하는 선택적인 기호입니다. UTF-16의 BOM은 'U+FEFF'인데, 빅-엔디안이면 'FE FF'로 나타나고 리틀-엔디안이면 'FF FE'로 나타납니다. UTF-8의 BOM은 'EF BB BF'입니다. 꼭 써야 할 필요는 없지만 BOM을 지정하면 어떤 인코딩을 사용하고 있는지 명시할 수 있습니다.

프로그램 구상(설계)

이 장에서는 앞서 논의한 기반 지식들을 바탕으로 어떤 장난감(프로그램)으로 만들어 낼 것인지를 살짝 생각해 보도록 하겠습니다. 실제로 코딩을 하기 전 어떻게 코딩을 해야 할지를 결정하는 중요한 단계입니다. 설계가 잘 되어야 결과가 잘 나오는 법입니다.

프로그램의 동작 순서 구상

우리의 장난감이 동작하는 순서부터 대략적으로 생각해 보도록 하지요.

  1. 프로그램은 MP3 파일의 목록을 입력받습니다.
  2. 프로그램은 쉘을 하나 실행합니다. 몇 개의 간단한 명령만 이해하는 단순한 녀석입니다. 이걸로 프로그램은 사람으로부터 명령을 받아들입니다.
  3. 사람으로부터 입력한 명령을 해석합니다.
  4. 어떤 명령인지를 분석하여 실행합니다. 태그 검색 명령의 경우, 다음 순서를 따라 움직여야 합니다.
    1. 사람으로부터 검색어를 입력받습니다. 검색어 초기 값을 제안할 수 있습니다.
    2. 입력받은 검색어를 확인합니다. 확인받으면 OpenAPI를 통해 질의를 보냅니다.
    3. 질의에 대한 응답을 수신합니다.
    4. 응답 문서를 해석해 사람에게 간단히 출력합니다.
    5. 사람은 응답 중 어떤 항목이 노래에 맞는 태그인지 선택합니다.
    6. 선택에 대해 확인받고, 선택 내용을 MP3 태그에 입력합니다.
    7. 추가적인 사항을 OpenAPI를 통해 검색할 수 있으며, 응답 사항의 일부를 MP3 태그에 추가적으로 기록할 수도 있습니다.
  5. 명령이 올바르지 않은 경우 에러를 내고 다시 명령을 대기하여야 합니다.
  6. 프로그램에서 에러가 나는 경우에도 쉘이 함부로 종료하는 경우는 없어야 합니다. 예를 들어 인터넷 연결 상태가 좋지 못해 일시적으로 질의에 대한 수신을 받을 수 없는 경우라도 에러 메시지를 출력하되, 프로그램이 갑자기 종료해버리는 경우는 없어야 합니다.

프로그램의 인터페이스 구상

일단 저는 프로그램을 최대한 간단히 만들고 싶습니다. 물론 사용하는 이를 충분히 고려해야 한다면 두말할 필요 없이 GUI 제작을 해야 하겠죠. 하지만 이 장난감은 거의 '(사용하는 사람) = (만드는 사람)' 이란 공식이 성립하는 듯하므로 인터페이스는 CLI 로 만족하고 싶습니다. 사실 인터페이스 만드는 것은 참 귀찮은 일입니다. 안 만드면 쓰기 너무 불편하고, 그러자고 편하기 만들자니 너무 만들기 힘들고… 아무튼 진절머리 나지 않는 선에서 적당히 끊도록 하겠습니다.

쉘 명령어 구상

파일 목록의 번호

입력 받은 파일의 목록은 프로그램 내부에서 리스트로 관리됩니다. 파일 목록의 시작은 1부터입니다.

load 명령

MP3 파일의 목록을 불러옵니다. 인자로 두 가지 형태의 파일 이름을 입력할 수 있습니다. 첫번째 형태는 MP3 파일 이름입니다. 다른 하나는 텍스트 파일입니다. 텍스트 파일 안에는 한 줄에 하나씩 MP3 파일의 경로가 입력되어 있습니다. 텍스트 파일 앞에는 '@' 기호를 붙여 MP3 파일 이름과 그 종류가 다름을 구분합니다.

load C:\....\Music\a.mp3 b.mp3 (절대경로인 C:\....\Music\a.mp3와 상대경로인 b.mp3 파일을 입력받습니다)

== list.txt 예==
C:\....\Music\c.mp3
C:\....\Music\d.mp3
C:\....\Music\e.mp3

load a.mp3 b.mp3 @list.txt (a.mp3, b.mp3와 배열 파일 'list.txt'를 입력받습니다 그러므로 a.mp3 ~ e.mp3 총 5개의 파일을 입력받습니다)

음악 파일 형식은 MP3만 가능합니다. 또한 프로그램은 중복 입력된 파일에 대해서 검사하지 않습니다. 중복 검사는 사전에 하셔야 합니다.

unload 명령

load 명령의 역입니다. 인자 목록을 입력받을 수 있습니다. 인자로 받은 항목은 파일 목록에서 삭제됩니다(목록에서 지워질 뿐 파일이 실제로 삭제되지는 않습니다).

unload 1 (1번 항목을 삭제합니다)
unload 6-9 (6~9번 항목을 삭제합니다. 하이픈 앞뒤 숫자와 공백이 없습니다. 나머지 항목도 동일합니다.)
clear 명령

목록을 전부 삭제합니다. 입력된 인자는 무시됩니다.

list 명령

입력받은 파일의 목록을 출력합니다. 인자 목록을 입력받을 수 있습니다.

list 1-5 (1번에서 5번까지 출력합니다)
list 10- (10번부터 마지막까지 출력합니다)
list -7  (처음부터 7번까지 출력합니다)
det 명령

'detail'을 줄여서 썼습니다. 인자로 하나의 숫자를 입력받습니다. 숫자에 해당하는 MP3 파일에 대한 보다 자세한 설명을 출력합니다.

det 10 (파일 목록 10번에 대한 자세한 정보를 출력합니다)
retr 명령

'retrieve'를 줄여서 썼습니다. 인자 목록을 입력받을 수 있습니다. 목록의 MP3 파일에 대해 태그 검색 및 수정을 수행합니다.

retr 1-10 (1번부터 10번까지 태그 검색/수정을 시작합니다)
retr 1 7-9 19- (1, 7, 8, 9, 그리고 19번부터 목록의 마지막까지 태그 검색 및 수정을 합니다)
retr -3 5 (1, 2, 3, 5번 목록에 대해 태그 검색 및 수정을 합니다)
retrall 명령

'retrieve all'을 줄여서 썼습니다. 인자는 필요하지 않으며 모든 수정하지 않은 항목에 대해 검색을 수행합니다.

nresp 명령

'number of response'. OpenAPI 질의를 할 때 최대 몇 곡까지 검색 결과로 가져올지를 정합니다. 1개의 인자를 입력받을 수 있습니다. 인자 없이 입력한 경우 현재 설정값을 출력합니다. 기본값은 10입니다.

exp 명령

'export'를 줄여서 썼습니다. 목록의 모두를 입력된 경로의 파일로 기록합니다.

exp all_list.txt (all_list.txt 파일에 현재 MP3 목록을 출력)
expunt 명령

'export untagged'를 줄여서 썼습니다. 현재 프로그램에 의해 수정되지 않은 목록만을 따로 파일에 기록합니다.

expunt untagged.txt (untagged.txt 파일에 현재 수정되지 않은 목록만을 출력)
exptag 명령

'export tagged'를 줄여서 썼습니다. expunt 명령과 반대로 이 명령은 현재 프로그램에 의해 태그가 수정된 목록만을 따로 파일에 기록합니다.

exptag tagged.txt (tagged.txt 파일에 현재 수정된 목록을 출력)
exit 명령

프로그램을 종료하고 명령 프롬프트로 돌아갑니다.

인자 에러

쉘은 인자를 입력받을 때 잘못된 형식의 인자를 받을 수 있습니다. 이는 인자 에러에 해당합니다. 그러면 쉘은 해당하는 에러에 대한 메시지를 출력하고 그 명령 자체를 무시합니다. 다음은 인자 에러에 해당하는 경우입니다.

  • 목록의 범위를 벗어난 숫자
  • 숫자를 입력받아야 하는 인자가 숫자로 변경 불가능한 경우
  • 잘못된 경로가 입력된 경우
  • 잘못된 파일이 입력된 경우
명령 에러

위 명령에 해당하지 않는 명령을 입력한 경우 에러 메시지를 출력하고 다시 입력을 대기합니다.

Maniadb.com OpenAPI 살펴보기

키(Key) 발급

Maniadb의 OpenAPI를 이용하려면 '키(key)'를 발급받으셔야 합니다. 키를 발급받는데 있어 제약 사항은 없지만 OpenAPI는 키 없이는 이용할 수 없습니다. 키를 발급받으려면 Maniadb에 로그인해야 하는데, 아마 직접 회원 가입은 되지 않으므로 대신 'OpenID'를 이용하여 로그인을 해야 합니다. 간단하게 'myID.net'에서 OpenID 계정을 만들고 이 계정으로 Maniadb에 로그인하면 됩니다. 로그인 후 'My Page'에서 'openapi key'를 확인할 수 있습니다.

*주: 보통 OpenAPI를 이용하기 위해서는 키가 틀려서는 안 됩니다. 그러나 현재 Maniadb는 key 값에 대해 체크를 하지 않는 것 같습니다. 아무 값을 넣어도 질의 응답이 이뤄집니다.

OpenAPI 둘러보기

OpenAPI에 관한 간략한 문서는 http://www.maniadb.com/api/apispec.asp 에서 확인할 수 있습니다. 검색은 앨범, 곡명, 아티스트 이렇게 세 가지 범주에서 가능합니다. 2013년 1월 3일 현재 버전 0.4까지 발표되어 있으나, 우리가 이용할 곡명 범주는 아직 구현되어 있지 않으므로 이전 버전인 0.3을 이용하여 검색을 수행할 것입니다.

질의 보내기

질의(쿼리, query)를 보내는 것은 별 어려움이 없습니다. 몇 번 해 보시면 쉽게 터득하실 수 있을 것입니다. 다음은 쿼리의 한 예입니다. v0.3의 API로 곡명 검색을 합니다. 노래 제목은 '하여가'이고, 가수는 '서태지와 아이들'입니다(주: query2 값을 '서태지와+아이들'로 할 경우 정규 2집 앨범의 하여가가 검색되지 않습니다. 아티스트 항목이 '서태지'로 되어 있기 때문입니다. 아이들 무시? ;-)).

http://www.maniadb.com/api/search.asp?key=(키값)&v=0.3&target=music&itemtype=song&option=song&query=하여가&display=20&option2=artist&query2=서태지
  

다음은 앨범 정보에 대한 검색입니다. 121480이라는 값은 Maniadb 내부에서 사용하는 앨범의 ID입니다.

http://www.maniadb.com/api/album.asp?key=(키값)&v=0.3&a=121480

query, query2 변수의 값은 각각 song, artist로 지정하거나 아예 query2를 넣지 않는 것이 가장 이상적이었습니다. 그러므로 구현할 때는 그렇게 사용하겠습니다.

응답 받기

질의의 대한 응답은 XML 문서로 전달됩니다. 아래는 '하여가' 노래 검색에 대한 응답 일부입니다. “<![CDATA[…]]>“라고 되어 있는 것은 XML 문서 중 CDATA 섹션이라는 것인데, XML 파서에게 이 부분은 아무런 처리를 하지 말고 그냥 그대로 지나가라고 지시하기 위한 용도입니다. 그래서 이 영역 안에 있는 텍스트는 안전하게 문자열로 해석됩니다.

<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:maniadb="http://www.maniadb.com/api/apispec.asp" version="2.0">
<channel>
  <title>Maniadb Open API - search : song</title>
  <link>www.maniadb.com</link>
  <description>
    <![CDATA[Maniadb Search Song Result
     : song - [하여가]
     : artist - [서태지]
     : elapsed_time - [0.1132813]
    ]]>
  </description>
  <lastBuildDate>Fri, 4 Jan 2013 08:15:00 +0900</lastBuildDate>
  <total>14</total>
  <start>1</start>
  <display>14</display>
  <item id="3498269">
    <title>
      <![CDATA[하여가]]>
    </title>
    <runningtime>
    </runningtime>
    <link>
      <![CDATA[http://www.maniadb.com#?s=3498269]]>
    </link>
    <pubDate>
      <![CDATA[Tue, 16 Aug 2011 10:23:00 +0900]]>
    </pubDate>
    <author>
      <![CDATA[maniadb]]>
    </author>
    <description>
      <![CDATA[너에게 모든 걸 뺏겨 버렸던 
      마음이 
      다시 내게 돌아오는 걸 
      느꼈지 
      너는 언제까지나 
      나만의 나의 연인이라 
      믿어왔던 내 생각은 
      틀리고 말았어 
      변해버린 건 ]]>
    </description>
    <guid>
      <![CDATA[http://www.maniadb.com#?a=3498269]]>
    </guid>
    <comments>
      <![CDATA[http://www.maniadb.com#?s=3498269#COMMENT]]>
    </comments>
    <maniadb:album>
      <title>
        <![CDATA[서태지와 아이들 2집 - Seotaiji And Boys II : 何如歌 (하여가) / 우리들만의 추억 (1993)]]>
      </title>
      <release>
        <![CDATA[19930621]]>
      </release>
      <link>
        <![CDATA[http://www.maniadb.com/album.asp?a=121480]]>
      </link>
      <image>
        <![CDATA[http://www.maniadb.com/images/album/121/121480_f_1.jpg]]>
      </image>
      <description>
        <![CDATA[서태지와 아이들의 두번째 앨범으로 레게 파마와 '하여가'로 10대의 우상이 될 수 있었던 앨범이다]]>
      </description>
    </maniadb:album>
    <maniadb:artist>
      <link>
        <![CDATA[http://www.maniadb.com/artist.asp?p=105569]]>
      </link>
      <name>
        <![CDATA[서태지]]>
      </name>
    </maniadb:artist>
  </item>
  ... (중략) ...
</channel>
</rss>

이 XML 문서에서 ID3 태그에 필요한 가장 기본적인 데이터는 아래 항목입니다.

데이터 태그 이름(item 태그를 기준)
노래 제목title
앨범 제목maniadb:album/title
발매 일자maniadb:album/release
앨범 커버maniadb:album/image
아티스트 명maniadb:artist/name

다음은 '서태지와 아이들 2집' 앨범 검색에 대한 응답 일부입니다.

<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:maniadb="http://www.maniadb.com/api/apispec.asp" version="2.0">
<channel>
  <title>Maniadb Open API - album</title>
  <link>http://www.maniadb.com</link>
  <description>Maniadb Album Information</description>
  <lastBuildDate>Sat, 15 Oct 2011 23:51:00 +0900</lastBuildDate>
  <maniadb:merchants>
    <shop>aladdin</shop>
    <shop>yes24</shop>
    <shop>libro</shop>
    <shop>interpark</shop>
    <shop>dnshop</shop>
    <shop>naver</shop>
    <shop>amazon</shop>
    <shop>pandoracd</shop>
  </maniadb:merchants>
  <item id="121480" seq="0">
     <title>
      <![CDATA[서태지와 아이들 2집 - Seotaiji And Boys II : 何如歌 (하여가) / 우리들만의 추억 (1993)]]>
     </title>
     <maniadb:shorttitle>
      <![CDATA[Seotaiji And Boys II]]>
     </maniadb:shorttitle>&gt;
    <maniadb:longtitle>
      <![CDATA[서태지와 아이들 2집 - Seotaiji And Boys II]]>
    </maniadb:longtitle>&gt;
    <link>
      <![CDATA[http://www.maniadb.com/album.asp?a=121480&s=0]]>
    </link>
    <releasedate>1993-06-21</releasedate>
    <thumnail>
      <![CDATA[http://img.maniadb.com/images/album_tnf/150_150/121/121480_f_1.jpg]]>
    </thumnail>
    <image>
      <![CDATA[http://img.maniadb.com/images/album/121/121480_f_1.jpg]]>
    </image>
    <description>
      <![CDATA[서태지와 아이들의 두번째 앨범으로 레게 파마와 '하여가'로 10대의 우상이 될 수 있었던 앨범이다]]>
    </description>
    <maniadb:artist>
      <id>
        <![CDATA[105570]]>
      </id>
      <name>
        <![CDATA[서태지와 아이들]]>
      </name>
    </maniadb:artist>
    <maniadb:tracks>
      <disc no="1">
        <title>
        </title>
        <song track="1" id="1026487">
          <title>
            <![CDATA[Yo! Taiji]]>
          </title>
          <runningtime>0:51</runningtime>
          <performer id="105570">
            <![CDATA[서태지와 아이들]]>
          </performer>
          <merchants>
            <shop name="24hz">http://24hz.com/s4j2f</shop>
          </merchants>
        </song>
        <song track="1" id="1026487">
          <title>
            <![CDATA[Yo! Taiji]]>
          </title>
          <runningtime>0:51</runningtime>
          <performer id="105570">
            <![CDATA[서태지와 아이들]]>
          </performer>
          <merchants>
          </merchants>
        </song>
        ... (중략) ...
      </disc>
    </maniadb:tracks>
    <maniadb:products>
      <product>
        <seqno>0</seqno>
        <releasedate>1993-06-21</releasedate>
        <release>
          <![CDATA[반도음반 (BDCD-017)]]>
        </release>
      </product>
    </maniadb:products>
  </item>
</channel>
</rss>

이미 대부분의 정보는 곡명 검색의 응답에서 취했습니다. 여기서 얻을 수 있는 정보는 아래와 같습니다.

데이터 태그 이름(item 태그를 기준)
디스크 정보maniadb:tracks/disc
트랙 정보maniadb:tracks/disc/song
발매회사maniadb:products/product/release

ID3 tag 편집 라이브러리

ID3 tag를 편집할 수 있는 파이썬 기반의 라이브러리는 몇 가지가 있습니다. 그 중에서 저는 eyed3를 이용하도록 하겠습니다. 이 라이브러리는 파이썬 2.7을 필요로 합니다. 설치 방법은 생략하도록 하겠습니다. 문서는 상당히 불친절한 편이긴 하지만, ID3 태그 구조를 조금 이해한다면 그렇게 어렵지만도 않습니다.

프로그램 제작

이제부터 본격적으로 프로그램을 제작하도록 하겠습니다. 생각보다 소스의 분량이 많습니다. 상세한 설명을 하지 못함을 양해바랍니다.

인코딩 관련 모듈

모든 인코딩이 하나로 통일되었다면 편리한데, 보통 한글 윈도우에서는 CP949를 이용하고, Maniadb의 쿼리는 UTF-8 인코딩을 요구합니다. 이 경우 표준 출력으로 한글 등을 출력할 때 문제가 발생할 수 있습니다. 그나마 문자가 깨지는 건 차라리 낫습니다. 어쩔 때는 인코딩 에러 예외가 발생되어 프로그램이 뜻하지 않게 종료되는 경우가 발생합니다. 이런 일을 막기 위해서는 출력 전에 미리 인코딩 변환을 해야 합니다. 그러므로 그런 역할을 맡은 모듈을 정의하도록 하겠습니다.

프로그램 소스의 문자 인코딩은 CP949입니다. 그리고 소스 및 내부 문자열은 모두 유니코드로 처리됨을 유념하십시오.

codeshift.py
# -*- coding: cp949 -*-
import locale
 
''' 
codeshift 모듈
 
로컬 인코딩, utf-8, 유니코드(utf-16) 인코딩을 변경하는 함수들로
명령 포롬프트의 코드 페이지가 변경되었다면
MANIADB_LOCAL_ENCODING 값을 적절히 변경 바람.
'''
 
MANIADB_LOCAL_ENCODING = locale.getdefaultlocale()[1]
 
# Local encoding (mainly, 'CP949') to 'UTF-8'
def loc2utf8(input):
    return input.decode(MANIADB_LOCAL_ENCODING).encode('utf-8', 'replace')
 
# 'UTF-8' to local encoding ('CP949')
def utf82loc(input):
    return input.decode('utf-8').encode(MANIADB_LOCAL_ENCODING, 'replace')
 
# Local encoding to unicode
def loc2uni(input):
    return input.decode(MANIADB_LOCAL_ENCODING, 'replace')
 
# UTF-8 to unicode
def utf82uni(input):
    return input.decode('utf-8', 'replace')
 
# Unicode to UTF-8
def uni2utf8(input):
    return input.encode('utf-8', 'replace')
 
# Unicode to local encoding
def uni2loc(input):
    return input.encode(MANIADB_LOCAL_ENCODING, 'replace')

OpenAPI 관련 모듈

OpenAPI 관련 모듈은 크게 2가지 파트로 나누어서 만들 생각입니다. 하나는 전단부로써 Maniadb 서버에 질의를 보내는 파트이고, 다른 하나는 후단부로 Maniadb의 XML 문서를 파싱하여 파이썬의 내장 데이터 타입인 딕셔너리와 리스트로 해석하는 작업을 맡습니다.

전단부, 후단부는 각각 아티스트/곡명/앨범을 검색하는 API, 앨범의 상세 정보를 요청하는 API 이렇게 2가지 종류에 의해 다시 세부적으로 나누집니다. 전체적으로 요약하면 아래 표와 같습니다.

파트 모듈 기능
전단부(서버→XML 문서)아티스트/곡명/앨범을 검색
앨범 상세 정보 요청
후단부(XML문서 → 파이썬 자료형)아티스트/곡명/앨범 검색 결과 파싱
앨범 상세 정보 결과 파싱

XML 문서 구조를 딕셔너리와 리스트로 만드는 것은 XML 문서를 JSON(Javascript Style Object Notation) 스타일로 표현하는 것과 많이 유사합니다. 각 엘리먼트의 태그 이름이나 어트리뷰트는 딕셔너리의 키가 됩니다. 엘리먼트 내부의 텍스트나 어트리뷰트의 값은 딕셔너리 키의 값으로 이용합니다. 엘리먼트 내부의 엘리먼트들은 리스트를 이용하여 열거할 수 있습니다. 간단히 아래 예로 설명하도록 하겠습니다.

<a>
    <b>This</b>
    <c>is</c>
    <d e='example'>foo</d>
    <f>
          <g>bar!</g>
          <h>spam</h>
    </f>
</a>

위 XML 문서는 아래와 같이 키와 리스트로 표현할 수 있습니다. 물론 이 방법만이 유일한 방법은 아닙니다.

{
 'a':
      {
       'b': 'This',
       'c': 'is',
       'd': [
              {
               'e': 'example',
              },
              'foo'
            ],
       'f': {
             'g': 'bar!',
             'h': 'spam',
            },
      },
}

전단부(서버->XML 문서)

전단부는 Maniadb에 질의를 보내 원하는 정보를 검색한다는 공통적은 목적을 가지고 있습니다. 그러므로 이러한 몇 가지 공통적인 부분을 모아 부모 클래스로 만듭니다. 이렇게 해 두면 차후 수정 이나 확장을 해야 할 때 간편해집니다.

api_base_v3.py
# -*- coding: cp949 -*-
 
class api_base_v3(object):
    ''' Maniadb OpenAPI v0.3 기초 모듈 '''
 
    @property
    def api_key(self):
        return self.__API_KEY
 
    @property
    def api_version(self):
        return self.__API_VERSION
 
    @property
    def max_buffer(self):
        return self.__MAX_BUFFER
 
    @max_buffer.setter
    def max_buffer(self, value):
        self.__MAX_BUFFER = value
 
    __API_KEY     = u''
    __API_VERSION = u'v=0.3'
    __MAX_BUFFER  = 10485760 #  10KiB

키 값은 직접 입력하시기 바랍니다. 그러면 이제 아티스트/곡명/앨범 검색을 위한 모듈을 작성하도록 하겠습니다. 이 모듈의 클래스는 1개인데, 질의를 보내는 URL을 만드는 함수, 그리고 질의를 보내 응답을 받는 함수 단 2개로 단촐하게 구성되어 있습니다.

api_search_v3.py
# -*- coding: cp949 -*-
import urllib2
import codeshift
import util
from   api_base_v3 import api_base_v3
 
class api_search_v3(api_base_v3):
    ''' Maniadb OpenAPI v0.3 키워드 검색 모듈 '''
 
    # Search for data. If an error happens, returns None
    def search(self, itemtype, option, query, option2 = u'', query2 = u'', display = 10):
        '''
        키워드 기반 검색 (앨범/아티스트/곡목) 함수.
        함수에서 직접 URL을 UTF-8로 변경하므로 인수는 유니코드로 전달하여야 한다.
 
        **NOTE**
        itemtype과 option을 동일하게 적지 않으면 올바로 검색이 되지 않는다.
        query2의 값은 적지 않아도 무방하나, artist 이외의 종류는 서버에서 올바로 검색하지 못한다.
 
        Args:
             itemtype: 어떤 검색을 수행할지 결정한다. 'album/artist/song' 중 택일.
             option:   질의어로 어떤 종류를 선택할지 결정한다. 마찬가지로 'album/artist/song' 중 택일.
             query:    질의어. 공백은 자동으로 '+'으로 치환된다.
             option2:  두 번째 질의어를 선택적으로 지정할 수 있다. 실질적으로 artist 이외는 효력이 없다.
             query2:   질의어. option2와 query2는 모두 공백이 아닌 문자일 때만 유효하다.
             display:  응답으로 전달되는 XML 문서에 최대 몇 개까지의 결과를 담을지 지정할 수 있다.
 
        Returns:
            서버로부터 전달된 응답 문서. XML 형식으로 되어 있다.
            만일 네트워크 장애로 XML 문서를 전달받지 못한 경우 None을 리턴한다.
 
        Raises:
        '''
        request_url = self.__make_request_url(itemtype, option, query, option2, query2, display)
 
        try:
            req = urllib2.urlopen(codeshift.uni2utf8(request_url))
 
        except Exception as e:
            util.trace_error(u'An error in api_album_v3:', e)
            return None
 
        return req.read(self.max_buffer)
 
    def __make_request_url(self, itemtype, option, query, option2, query2, display):
        _url = self.__REQUEST_BASE_URL
        _url += u'?key='      + self.api_key
        _url += u'&'          + self.api_version
        _url += u'&target='   + u'music'
        _url += u'&itemtype=' + itemtype
        _url += u'&option='   + option
        _url += u'&query='    + query.replace(u' ', u'+')
 
        if option2 != ''  and query2 != '':
            _url += u'&option2=' + option2
            _url += u'&query2='  + query2.replace(u' ', u'+')
 
        _url += u'&display='  + str(display)
 
        return _url
 
    __REQUEST_BASE_URL = u'http://www.maniadb.com/api/search.asp'
 
if __name__ == '__main__':
    s = api_search_v3()
    r = s.search(u'song', u'song', u'Never ending stroy', u'부활')
 
    if r:
        print codeshift.utf82loc(r)

이번에는 앨범의 상세 정보를 요청하는 모듈 차례입니다. 앨범의 상세 정보는 질의를 던지는 쪽이 앨범에 대한 ID를 알고 있어야 합니다. 이 ID는 Maniadb 내부에서 앨범을 식별/관리하기 위한 용도로 사용되고 있습니다.

api_album_v3.py
# -*- coding: cp949   -*-
import urllib2
import codeshift
import util
from   api_base_v3 import api_base_v3
 
class api_album_v3(api_base_v3):
    ''' Maniadb OpenAPI v0.3 앨범 상세 정보 검색 클래스 '''
 
    def search(self, album_id):
        '''
        앨범 상세 정보 검색 함수
 
        Args:
            album_id: 앨범의 아이디. Maniadb에서 자체적으로 앨범마다 지정한 수.
                      문자열로 된 숫자나 실제 정수형이나 관계없이 입력 가능.
                      텍스트의 경우 가급적 유니코드로 입력할 것.
 
        Returns:
            서버로부터 전달된 응답 문서. XML 형식으로 되어 있다.
            만일 네트워크 장애로 XML 문서를 전달받지 못한 경우 None을 리턴한다.
 
        Raises:
        '''
        request_url = codeshift.uni2utf8(self.__make_request_url(album_id))
 
        try:
            req = urllib2.urlopen(request_url)
 
        except Exception as e:
            util.trace_error(u'An error in api_album_v3:', e)
            return None
 
        return req.read(self.max_buffer)
 
    def __make_request_url(self, album_id):
        ''' 요청 URL 생성 함수 '''
        _url = self.__REQUEST_BASE_URL
        _url += u'?key=' + self.api_key
        _url += u'&'     + self.api_version
        _url += u'&a='   + str(album_id)
        return _url
 
    __REQUEST_BASE_URL = u'http://www.maniadb.com/api/album.asp'
 
if __name__ == '__main__':
 
    album = api_album_v3()
    r     = album.search(121480)
 
    if r:
        print codeshift.utf82loc(r)

후단부(XML문서 -> 파이썬 자료형)

XML 문서가 성공적으로 전달되어 왔어도 이는 그저 텍스트일 뿐입니다. 프로그램 상에서 의미 있는 정보로 만들기 위해 파싱을 해야 합니다. 후단부는 XML 문서를 파싱하는 역할을 맡습니다. 후단부에도 아티스트/곡명/앨범 검색에 대한 문서 파싱을 하는 세부 모듈과 앨범 상세 정보에 대한 문서 파싱을 하는 세부 모듈을 각각 제작해 보겠습니다.

일단 이 모듈은 차후에 범용적으로 쓰일 수 있을 거라 가정하고, 우리 프로그램에서 필요한 데이터 뿐만이 아니라 Maniadb로부터 오는 정보의 대부분에 대해 파싱을 하는 것으로 하겠습니다.

단, 파이썬 자료형이라고 해서 숫자나 날짜 등의 데이터에 대해 일일이 파이썬 내장 자료형으로 변환하지는 않을 것입니다. ID3 태그가 그렇듯 각 값들은 모두 단순 문자열로써만 처리될 것입니다. 만약 그런 데이터 타입이 필요하다면 직접 변환을 하셔야 합니다.

우선 파싱하는 모듈의 부모 클래스를 제작합니다. 부모에서 공통적인 역할을 하는 속성/함수를 정의합니다.

parse_base_v3.py
# -*- coding: cp949 -*-
import re, xml.dom.minidom, HTMLParser
import codeshift
 
class parse_base_v3(object):
    def __init__(self, parseText):
        _parseText    = self._make_neat_xml(parseText)
        self.document = xml.dom.minidom.parseString(_parseText)
 
    # get text data.
    # item: Parent node of element_name. This node will be the top node to be searched.
    #       Only the first element_name element within item node will be chosen.
    #       If item node is None, this fuction returns an empty string.
    #
    # element_name: Target element that contains a text node.
    #               It must have the only child, and the only child should be a text node.
    #               If not, this function returns an empty string.
    def _get_text_data(self, item, element_name):
 
        # html entity 제거용 모듈
        par      = HTMLParser.HTMLParser()
        unescape = par.unescape
 
        e = item.getElementsByTagName(element_name)
        if e is None:
            return u''
 
        element = e[0]
        if element is not None and \
           element.firstChild is not None and \
           (element.firstChild.nodeType == xml.dom.Node.CDATA_SECTION_NODE or \
            element.firstChild.nodeType == xml.dom.Node.TEXT_NODE):
            return unescape(element.firstChild.data.strip())
        else:
            return u''
 
    # remove all whitespaces between two adjacent tags.
    def _make_neat_xml(self, text):
        return re.sub(r'> +<', codeshift.uni2utf8(u'><'), re.sub(r'\s', codeshift.uni2utf8(u' '),  text))
 
    @property
    def document(self):
        return self.__document
 
    @document.setter
    def document(self, doc):
        self.__document = doc
 
    @property
    def result(self):
        return self.__result
 
    __document = None
    __result   = {}

_make_neat_xml 함수는 XML 문서의 인접한 태그 사이에 있는 불필요한 공백을 삭제합니다. 사람이 보기 좋게 들여쓰기를 잘 맞춘 XML 문서는 시각적으로는 보기 좋지만, DOM Tree를 만들 때 불필요한 텍스트 노드가 많이 생깁니다. 이를 삭제하여 공백에 의해 발생하는 불필요한 노드를 줄입니다. DOM Tree를 이용하므로 사실상 이 작업이 꼭 필요한 것은 아닙니다.

이제 아티스트/곡명/앨범 검색 결과를 파싱하는 모듈을 만듭니다. 각 태그의 항목을 일일이 추출하는 것이라 상당히 길고 까다로웠습니다.

parse_search_v3.py
# -*- coding: cp949 -*-
from parse_base_v3 import parse_base_v3
 
class parse_search_v3(parse_base_v3):
    def __init__(self, itemtype, parseText):
        super(parse_search_v3, self).__init__(parseText)
        self.__parse_common_response()
 
        if   itemtype == u'album':  self.__parse_album()
        elif itemtype == u'song':   self.__parse_song()
        elif itemtype == u'artist': self.__parse_artist()
        else: raise Exception(u'A wrong choice!')
 
    # common response
    def __parse_common_response(self):
        root    = self.document.documentElement
        channel = root.getElementsByTagName(u'channel')[0]
 
        self.result[u'title']         = self._get_text_data(channel, u'title')
        self.result[u'link']          = self._get_text_data(channel, u'link')
        self.result[u'description']   = self._get_text_data(channel, u'description')
        self.result[u'lastBuildDate'] = self._get_text_data(channel, u'lastBuildDate')
        self.result[u'total']         = self._get_text_data(channel, u'total')
        self.result[u'start']         = self._get_text_data(channel, u'start')
        self.result[u'display']       = self._get_text_data(channel, u'display')
 
    # 노래 검색에 대한 응답
    def __parse_song(self):
        root  = self.document.documentElement
        items = root.getElementsByTagName(u'item')
 
        self.result[u'item'] =   []
 
        for item in items:
            itembuf = {}
 
            ### id, title, runningtime, link, pubDate, author, description, guid, comments
            itembuf[u'id']          = item.getAttribute(u'id')
            itembuf[u'title']       = self._get_text_data(item, u'title')
            itembuf[u'runningtime'] = self._get_text_data(item, u'runningtime')
            itembuf[u'link']        = self._get_text_data(item, u'link')
            itembuf[u'pubDate']     = self._get_text_data(item, u'pubDate')
            itembuf[u'author']      = self._get_text_data(item, u'author')
            itembuf[u'description'] = self._get_text_data(item, u'description')
            itembuf[u'guid']        = self._get_text_data(item, u'guid')
            itembuf[u'comments']    = self._get_text_data(item, u'comments')
 
            ### album info: title, release, link, image, description
            album_info = item.getElementsByTagName(u'maniadb:album')[0]
            itembuf[u'album'] = {}
            itembuf[u'album'][u'title']       = self._get_text_data(album_info, u'title')
            itembuf[u'album'][u'release']     = self._get_text_data(album_info, u'release')
            itembuf[u'album'][u'link']        = self._get_text_data(album_info, u'link')
            itembuf[u'album'][u'image']       = self._get_text_data(album_info, u'image')
            itembuf[u'album'][u'description'] = self._get_text_data(album_info, u'description')
 
            ### artist info: link, name
            artist_info = item.getElementsByTagName(u'maniadb:artist')[0]
            itembuf[u'artist'] = {}
            itembuf[u'artist'][u'link'] = self._get_text_data(artist_info, u'link')
            itembuf[u'artist'][u'name'] = self._get_text_data(artist_info, u'name')
 
            ### 결과 append
            self.result[u'item'].append(itembuf)
 
    # 앨범에 대한 파싱 결과
    def __parse_album(self):
        root  = self.document.documentElement
        items = root.getElementsByTagName(u'item')
 
        self.result[u'item'] =   []
 
        for item in items:
            itembuf = {}
 
            ### title, release, link, thumbnail, image, pubDate, author, description, guid, comment
            ### NOTICE: 'thumbail'. The tag name has a typo.
            itembuf[u'id']          = item.getAttribute(u'id')
            itembuf[u'title']       = self._get_text_data(item, u'title')
            itembuf[u'release']     = self._get_text_data(item, u'release')
            itembuf[u'link']        = self._get_text_data(item, u'link')
            itembuf[u'thumbnail ']  = self._get_text_data(item, u'thumbail')
            itembuf[u'image']       = self._get_text_data(item, u'image')
            itembuf[u'pubDate']     = self._get_text_data(item, u'pubDate')
            itembuf[u'author']      = self._get_text_data(item, u'author')
            itembuf[u'description'] = self._get_text_data(item, u'description')
            itembuf[u'guid']        = self._get_text_data(item, u'guid')
            itembuf[u'comments']    = self._get_text_data(item, u'comments')
 
            ### artist info: link, name
            artist_info = item.getElementsByTagName(u'maniadb:artist')[0]
            itembuf[u'artist'] = {}
            itembuf[u'artist'][u'link'] = self._get_text_data(artist_info, u'link')
            itembuf[u'artist'][u'name'] = self._get_text_data(artist_info, u'name')
 
            ### 결과 append
            self.result[u'item'].append(itembuf)
 
    # 아티스트에 대한 검색
    def __parse_artist(self):
        root  = self.document.documentElement
        items = root.getElementsByTagName(u'item')
 
        self.result[u'item'] =   []
 
        for item in items:
            itembuf = {}
 
            ### title, reference, demographic, period, link, image, pubDate, author, descrption, guid, comments
            itembuf[u'id']          = item.attributes[u'id'].value
            itembuf[u'title']       = self._get_text_data(item, u'title')
            itembuf[u'reference']   = self._get_text_data(item, u'reference')
            itembuf[u'demographic'] = self._get_text_data(item, u'demographic')
            itembuf[u'period']      = self._get_text_data(item, u'period')
            itembuf[u'link']        = self._get_text_data(item, u'link')
            itembuf[u'image']       = self._get_text_data(item, u'image')
            itembuf[u'pubDate']     = self._get_text_data(item, u'pubDate')
            itembuf[u'author']      = self._get_text_data(item, u'author')
            itembuf[u'description'] = self._get_text_data(item, u'description')
            itembuf[u'guid']        = self._get_text_data(item, u'guid')
            itembuf[u'comments']    = self._get_text_data(item, u'comments')
 
            # 결과 append
            self.result[u'item'].append(itembuf)
 
if __name__ == '__main__':
    with open('song_sample.xml', 'r') as f:
        xmldoc = f.read()
        ps = parse_search_v3(u'song', xmldoc)
        print ps.result
 
    with open('album_sample.xml', 'r') as f:
        xmldoc = f.read()
        ps = parse_search_v3(u'album', xmldoc)
        print ps.result
 
    with open('artist_sample.xml', 'r') as f:
        xmldoc = f.read()
        ps = parse_search_v3(u'artist', xmldoc)
        print ps.result

마찬가지로 앨범 세부 정보 XML 문서를 파싱하는 코드를 작성합니다.

parse_album_v3.py
# -*- coding: cp949 -*-
from parse_base_v3 import parse_base_v3
 
class parse_album_v3(parse_base_v3):
    '''
    앨범 상세 정보 파싱 클래스
 
    입력: XML 문서
    출력: 파싱된 콘텐츠. 파이썬 기본 데이터형인 딕셔너리와 리스트로 XML 내용을 표현.
 
   '''
 
    def __init__(self, parseText):
        super(parse_album_v3, self).__init__(parseText)
        self.__parse_response(self.document)
 
    def __parse_response(self, document):
        root  = document.documentElement
        item  = document.getElementsByTagName(u'item')[0]
 
        ### id, seq, title, shorttitle, longtitle, link, releasedate, thumnail, image, description
        ### NOTICE: 'thumbail'. The tag name has a typo.
        self.result[u'id']          = item.getAttribute(u'id')
        self.result[u'seq']         = item.getAttribute(u'seq')
        self.result[u'title']       = self._get_text_data(item, u'title')
        self.result[u'shorttitle']  = self._get_text_data(item, u'maniadb:shorttitle')
        self.result[u'longtitle']   = self._get_text_data(item, u'maniadb:longtitle')
        self.result[u'link']        = self._get_text_data(item, u'link')
        self.result[u'releasedate'] = self._get_text_data(item, u'releasedate')
        self.result[u'thumbnail']   = self._get_text_data(item, u'thumnail')
        self.result[u'image']       = self._get_text_data(item, u'image')
        self.result[u'description'] = self._get_text_data(item, u'description')
 
        ### artist info: id, name
        artist = document.getElementsByTagName(u'maniadb:artist')[0]
        self.result[u'artist'] = {}
        self.result[u'artist'][u'id']   = self._get_text_data(artist, u'id')
        self.result[u'artist'][u'name'] = self._get_text_data(artist, u'name')
 
        ### disc info
        self.result[u'disc'] = []
        discs = document.getElementsByTagName(u'disc')
        for disc in discs:
            disc_buf = {}
            disc_buf[u'no']    = disc.getAttribute(u'no')
            disc_buf[u'title'] = self._get_text_data(disc, u'title')
            disc_buf[u'song']  = []
 
            songs = disc.getElementsByTagName(u'song')
            trackno = 1
 
            for song in songs:
                song_buf = {}
                tag_no = int(song.getAttribute(u'track'))
                if trackno > tag_no: continue # 중복된 트랙 자료가 넘어옴
 
                song_buf[u'id']          = song.getAttribute(u'id')
                song_buf[u'track']       = song.getAttribute(u'track')
                song_buf[u'title']       = self._get_text_data(song, u'title')
                song_buf[u'runningtime'] = self._get_text_data(song, u'runningtime')
                song_buf[u'performer']   = self._get_text_data(song, u'performer')
 
                trackno += 1
                disc_buf[u'song'].append(song_buf)
 
            self.result[u'disc'].append(disc_buf)
 
        ### product info
        product_infos = item.getElementsByTagName(u'maniadb:products')[0]
        products      = product_infos.getElementsByTagName(u'product')
        self.result[u'product'] = []
 
        for product in products:
            product_buf = {}
 
            ### seqno, releasedate, release
            product_buf[u'seqno']       = self._get_text_data(product, u'seqno')
            product_buf[u'releasedate'] = self._get_text_data(product, u'releasedate')
            product_buf[u'release']     = self._get_text_data(product, u'release')
 
            self.result[u'product'].append(product_buf)
 
if __name__ == '__main__':
    with open('album_info_sample.xml', 'r') as f:
        xmldoc = f.read()
        pa = parse_album_v3(xmldoc)
        print pa.result

ID3Tag 편집 모듈

이 모듈은 프로그램에서 필요한 ID3 태그 편집 및 MP3 파일 정보와 관련된 모든 기능을 담당합니다.

id3tag_manip.py
# -*- coding: cp949 -*-
import re, urllib2
import eyed3
import codeshift
import util
 
class maniadb_tag_info:
    '''
    Maniadb 태그 정보 기록을 위한 클래스
 
    update_mp3_tag 일반함수의 내부에서 사용된다. 유저 코드에서 직접적으로 선언되어
    사용되는 일은 없다.
    '''
 
    def get_info(self, song_info, idx, album_info = None):
        '''
        <파싱된 콘텐츠>에서 MP3 태그 정보에 기록할 정보를 재차 추출한다.
        파싱된 콘텐츠란 'parse_search_v3', 'parse_album_v3'와 같은 모듈에 의해
        OpenAPI를 통해 전달 받은 XML문서의 구조를 파이썬의 딕셔너리와 리스트로
        변환시킨 것이다.
 
        이 클래스가 멤버 변수로 기록하는 내용은 다음과 같다.
 
        제목                     변수명          출처
        =============================================================
        앨범 제목                album_title     album_info, song_info (album_info 가 없으면)
        아티스트 이름            artist_name     song_info
        커버 이미지 데이터       cover_bin       http response
        커버 이미지 MIME type    cover_type      http response
        커버 이미지 URL          cover_url       song_info
        디스크 번호              disc_num        album_info
        레이블                   publisher       album_info
        발매일                   release_date    song_info
        곡명                     song_title      song_info
        트랙 번호                song_track      album_info
        전체 디스크 장수         total_disc      album_info
        디스크의 전체 트랙       total_track     album_info
 
        Args:
            song_info:  곡 정보 검색 결과 (파싱된 콘텐츠)
            idx:        곡 정보 검색 결과 중 한 아이템의 인덱스.
            album_info: 앨범 세부 정보 (파싱된 콘텐츠)
 
        Returns:
            모듈의 내부 변수에 지정된 값이 저장된다.
 
        Raises:
            일반 예외: 디스크/트랙 번호를 찾을 수 없을 경우에 발생한다.
                       Maniadb의 모든 데이터를 100% 신뢰할 수는 없다. 가끔씩
                       같은 곡의 ID가 song_info와 album_info에서 다르게 나올 때가 있다.
        '''
 
        # choose song item
        song_item        = song_info[u'item'][idx]
 
        # song tags
        if album_info != None:
            self.album_title  = album_info[u'shorttitle']
        else:
            self.album_title  = song_item[u'album'][u'title']
 
        self.artist_name  = song_item[u'artist'][u'name']
        self.song_title   = song_item[u'title']
        self.release_date = song_item[u'album'][u'release']
 
        # fix: some song title may have a html anchor tag. Get rid of it.
        detect_link = re.match(r'(.+)<a[^>]+>(.*)</a>(.*)', self.song_title)
        if detect_link:
            self.song_title = ''.join(detect_link.groups())
 
        # get url
        self.cover_url   = song_info[u'image']
 
        # get binary image and image type from url
        resp             = urllib2.urlopen(self.cover_url)
        self.cover_type  = resp.info()[u'Content-Type']
        self.cover_bin   = resp.read()
 
        # optional data
        if album_info != None:
            # search for disc number, track number
            disc_num, track_num = self.__search_song_id(song_item[u'id'], album_info[u'disc'])
 
            if disc_num == None or track_num == None:
                msg =  u'Song ID \''+song_item[u'id']+u'\' not found. '
                msg += u'Check \'album_result.xml\' and \'search_result.xml\' for reason'
                raise Exception(msg)
 
            self.song_track  = unicode(track_num) # song track number
            self.disc_num    = unicode(disc_num)  # disc number
            self.total_disc  = unicode(len(album_info[u'disc']))
            self.total_track = unicode(len(album_info[u'disc'][disc_num-1][u'song']))
            self.publisher   = album_info[u'product'][0][u'release'] # label
 
    # Just for dumping
    def dump(self):
        '''Dump all data'''
 
        print u'album_title:    %s' % self.album_title
        print u'artist_name:    %s' % self.artist_name
        print u'cover_bin size: %d' % len(self.cover_bin)
        print u'cover_url:      %s' % self.cover_url
        print u'disc_num:       %s' % self.disc_num
        print u'release_date:   %s' % self.release_date
        print u'publisher:      %s' % self.publisher
        print u'song_title:     %s' % self.song_title
        print u'song_track:     %s' % self.song_track
        print u'total_disc:     %s' % self.total_disc
        print u'total_track:    %s' % self.total_track
 
    # save image to file
    def save_cover_as(self, file_name_no_ext):
        '''Save cover image to a file. Not used normally.'''
 
        if self.cover_bin == '':
            raise Exception(u'No cover file contents')
 
        if self.cover_type == '':
            raise Exception(u'No mime type')
 
        ext      = self.cover_type.split(u'/')[1]
        file_name = file_name_no_ext + u'.' + ext
        with open(file_name, u'wb') as f:
            f.write(self.cover_bin)
 
    # find song_id in disc list
    def __search_song_id(self, song_id, discs):
        #print 'song_id:', repr(song_id)
        disc_num   = 0
        track_num  = 0
 
        # disc[u'no']는 간혹 숫자 대신 영문자 'A', 'B'등이 들어갈 수 있다.
        # 이는 태그 기록 시점에서 문제가 될 수 있으므로 가급적 피한다.
        # 디스크 번호와 마찬가지로 트랙 번호도 직접 integer 변수로 처리.
        for disc in discs:
            songs     = disc[u'song']
            #disc_num  = disc[u'no']
            disc_num  += 1
            track_num = 0
 
            for song in songs:
                track_num += 1
                #print u'song[u\'id\']:', repr(song[u'id'])
                if song[u'id'] == song_id:
                    return disc_num, track_num
 
        return None, None
 
    album_title  = u''
    artist_name  = u''
    cover_bin    = u''
    cover_type   = u''
    cover_url    = u''
    disc_num     = u''
    publisher    = u''
    release_date = u''
    song_title   = u''
    song_track   = u''
    total_disc   = u''
    total_track  = u''
 
### END OF CLASS maniadb_tag_info ###
 
# Update MP3 Tag from information
def update_mp3_tag(file_name, song_info, idx, album_info = None):
    '''
    mp3 태그를 새롭게 갱신한다. 기존의 태그 정보는 완전 삭제된다.
    mp3 태그 수정을 위해 eyed3 라이브러리를 사용하였다.
    태그 수정은 일괄적으로 ID3V2.3을 이용하도록 되어 있다.
 
    Args:
        file_name:  태그를 수정할 파일 이름
        song_info:  곡 정보 검색 결과 (파싱된 콘텐츠)
        idx:        곡 정보 검색 결과 중 한 아이템의 인덱스.
        album_info: 앨범 세부 정보 (파싱된 콘텐츠)
 
    Returns:
        없음.
 
    Raises:
        정의하지 않음.
 
    '''
 
    # Maniadb tag information class
    tag_info = maniadb_tag_info()
 
    # Get information from information object
    tag_info.get_info(song_info, idx, album_info)
 
    # DUMP for debugging
    tag_info.dump()
 
    # Remove current tag
    eyed3.id3.tag.Tag.remove(file_name)
 
    # Load file
    audio_file = eyed3.load(file_name)
 
    # Input new tag
    audio_file.tag                = eyed3.id3.Tag()
    audio_file.tag.header.version = eyed3.id3.ID3_V2_3           # ID3V2.3
    audio_file.tag.album          = tag_info.album_title         # Album title
    audio_file.tag.artist         = tag_info.artist_name         # Artist Name
    audio_file.tag.disc_num       = (tag_info.disc_num, tag_info.total_disc) # Disc number
    audio_file.tag.publisher      = tag_info.publisher           # Publisher
    audio_file.tag.title          = tag_info.song_title          # Song title
    audio_file.tag.track_num      = (tag_info.song_track, tag_info.total_track) # Current track number, total track number
 
    # Image
    imagetype = eyed3.id3.frames.ImageFrame.stringToPicType('FRONT_COVER')
    audio_file.tag.images.set(imagetype, tag_info.cover_bin, tag_info.cover_type)
 
    # optional, TYER, release date
    reldate = tag_info.release_date
 
    if reldate != '':
        try:
            year  = int(reldate[0:4])
            month = int(reldate[4:6])
            day   = int(reldate[6:8])
 
            if month == 0: month = None
            if day == 0:   day = None
 
            audio_file.tag.release_date = eyed3.core.Date(year, month, day) # Release date
            audio_file.tag.setTextFrame(u'TYER', unicode(year)) # Year of release. Default comlumn in Mp3Tag.
 
        except ValueError:
            print u'\'release_date\' seems to be wrong. value:', reldate
            print u'Skip writing release date tag'
 
        except IndexError:
            print u'\'release_date\' does not have \'yyyymmdd\' format:', reldate
            print u'Skip writing release date tag'
 
    # Save
    audio_file.tag.save(file_name)
 
# MP3 File information.
def mp3info(file_name):
    '''
    MP3 파일과 태그의 정보를 검색한다.
 
    Args:
        file_name: 검색할 파일명.
 
    Returns:
        정보를 담은 파이썬 딕셔너리 객체.
        딕셔너리 객체는 다음과 같은 정보를 가지고 있다.
 
        키              값
        ========================================
        tag_ver         태그 체계의 버전. 튜플로 표시된다. e.g. (2, 3, 0)
        path            파일의 절대 경로.
        song            노래 제목.
        artist          아티스트 이름.
        album           앨범 제목.
        release_date    출시일 (발매일).
        time            재생시간 hh:mm:ss 혹은 mm:ss 로 표시된다.
        vbr             True/False 값으로 VBR (Variable Bitrate) 여부를 표시.
        bit_rate        Bitrate를 표시. 단위는 kbps.
        size_bytes      파일 크기를 표시. 단위는 바이트.
        disc_num        디스크 번호. (현재 디스크, 전체 디스크 수) 튜플로 표시.
        track_num       트랙 번호. (현재 트랙, 전체 트랙) 튜플로 표시.
        publisher       앨범을 출시한 레이블 이름.
 
    Raises:
        정의하지 않음.
 
    '''
 
    audio_file = eyed3.load(file_name)
    tag        = audio_file.tag
    info       = audio_file.info
 
    tag_info = {
        u'tag_ver':      '',
        u'path':         '',
        u'song':         '',
        u'artist':       '',
        u'album':        '',
        u'release_date': '',
        u'time':         '',
        u'vbr':          '',
        u'bit_rate':     0,
        u'size_bytes':   0,
        u'disc_num':     None,
        u'track_num':    None,
        u'publisher':    '',
    }
 
    ### Some information available even though tag doesn't exist
    # path
    tag_info[u'path'] = audio_file.path
 
    # time
    tag_info[u'time'] = sec2hms(info.time_secs)
 
    # vbr
    tag_info[u'vbr'] = info.bit_rate[0]
 
    # bitrate
    tag_info[u'bit_rate'] = info.bit_rate[1]
 
    # size_bytes
    tag_info[u'size_bytes'] = info.size_bytes
 
    if tag is None:
        return tag_info
 
    ### Tag information
    # tag version
    if tag.header.version is not None:
        tag_info[u'tag_ver'] = tag.header.version
 
    # song title
    if tag.title is not None :
        tag_info[u'song'] = tag.title
 
    # artist
    if tag.artist is not None :
        tag_info[u'artist'] = tag.artist
 
    # album title
    if tag.album is not None :
        tag_info[u'album'] = tag.album
 
    # release date
    if tag.release_date is not None:
        tag_info[u'release_date'] = tag.release_date
 
    # disc number
    if tag.disc_num is not None:
        tag_info[u'disc_num'] = tag.disc_num
 
    # track number
    if tag.track_num is not None:
        tag_info[u'track_num'] = tag.track_num
 
    # publisher
    if tag.publisher is not None:
        tag_info[u'publisher'] = tag.publisher
 
    return tag_info
 
def sec2hms(time_sec):
    ''' 초를 hh:mm:ss 혹은 mm:ss 형식의 텍스트로 변경 '''
    sec  = time_sec
    hour = sec/3600
    sec  = sec%3600
    min  = sec/60
    sec  = sec%60
 
    if hour > 0:
        return u'%02d:%02d:%02d' % (hour, min, sec)
    else:
        return u'%02d:%02d' % (min, sec)

콘솔 메뉴 모듈

콘솔 인터페이스과 관련된 모듈들입니다. 프롬프트이므로 키보드 입력만을 받을 것인데, 사실 간단한 키보드 입력도 짜임새 있게 구현하려면 귀찮아지기 마련입니다.

콘솔 메뉴 또한 기본 클래스를 정의하고, 각 용도마다 각기 클래스를 정의하여 용도별로 나누었습니다.

interface_base.py
# -*- coding: cp949 -*-
import codeshift
 
 
class interface_base:
    '''
    기본 인터페이스 클래스
 
    CLI 기반의 쉘을 제외한 모든 사용자 인터페이스는 본 클래스를 상속받아 구현됩니다.
    CLI로 키 입력, 명령 확인, 입력값 검사 등의 절차를 간결히 구현하는 것이 목적입니다.
    하나의 입력 절차를 하나의 클래스로 정의합니다.
 
    '''
 
    def before_loop(self):
        ''' 메뉴 핸들러 루프 진입 전에 단 한 번 불리는 함수 '''
        pass
 
    def after_loop(self):
        ''' 메뉴 핸들러 루프 탈출 후 단 한 번 불리는 함수 '''
        pass
 
    def menuhandler(self, menustr, commands, callbacks):
        '''
        메뉴 핸들러 함수
 
        표준 입력으로부터 한 줄을 입력 받아, 입력받은 문자열을 공백 기준으로 분해해
        리스트로 만든다. 첫번째 리스트 원소가 '명령'이고, 나머지는 '인자 목록'이 된다.
        인자 목록은 None이 될 수 있다.
 
        Args
            menustr: 표준 출력으로 출력될 문자열.
                     메뉴에 대한 설명을 적을 수 있다.
 
            commands: 핸들러가 처리할 명령어 목록들. 반드시 리스트 형으로 입력.
                      명령어는 한번에 2개 이상을 입력할 수 없다.
                      매직 워드인 '*' 문자를 사용할 수 있으며, 이는 입력된 어떤
                      명령과도 매치될 수 있다. 그러므로 매직 워드는 보통 리스트의
                      가장 마지막 원소로 두는 것이 좋다.
                      리스트의 길이는 callback 인자의 리스트 수와 일치해야 한다.
                      길이는 0이 될 수 없다.
 
            callbacks: 핸들러 함수의 목록. 반드시 리스트 형으로 입력하여야 한다.
                       콜백 함수는 하나의 인자를 입력받는다. 이 인자는 특정 명령어에
                       매칭되어 실행된 경우 입력된 인자의 목록이 되며, 매직 워드와
                       매칭되어 실행된 경우라면 입력한 문자열이 된다.
                       콜백 함수는 반드시 True나 False 중 하나를 리턴해야 한다.
 
        Returns
            콜백 함수의 리턴값(True/False). 보통 이 값을 이용해 상속받은 클래스에서
            다음 단계로 넘어갈지, 아니면 다시 처음부터 입력 절차를 밟을지를 결정한다.
 
        Raises
            일반 예외. 인자인 commands와 callbacks가 리스트가 아닌경우,
            리스트라도 길이가 0인 경우나 서로의 길이가 일치하지 않을 경우 일어난다.
 
        '''
 
        if type(commands) != list or type(callbacks) != list:
            raise Exception(u'consolemenu error. \'commands\' and \'callbacks\' should be list types.')
 
        if len(commands) != len(callbacks):
            raise Exception(u'consolemenu error. Length of two lists are not the same.')
 
        if len(commands) == 0:
            raise Exception(u'consolemenu error. Zero-length list')
 
        self.before_loop()
 
        # the loop
        while True:
            print menustr,
            str = codeshift.loc2uni(raw_input())
            inp = str.split()
 
            if len(inp) == 0:
                for i in xrange(len(commands)):
                    if commands[i] == u'*':
                        return callbacks[i](str)
            else:
                cmd  = inp[0]
                args = None
 
                if len(inp) > 1:
                    args = inp[1:]
 
                for i in xrange(len(commands)):
                    if commands[i] == u'*':
                        return callbacks[i](str)
 
                    elif cmd == commands[i]:
                        return callbacks[i](args)
 
            print self.nocmd_msg % str
 
        self.after_loop()
 
    ''' 인터페이스가 최종적으로 상위 모듈에게 전하는 응답 '''
    __response = 0
 
    ''' 사용자가 입력한 명령이 명령어 리스트에 존재하지 않을 때 출력하는 메시지 '''
    nocmd_msg = u'command \'%s\' is not in the command list.'
interfaece.py
# -*- coding: cp949 -*-
import codeshift
import id3tag_manip
from   interface_base import interface_base
 
'''
Interface 모듈
 
CLI 환경에서 사용자로부터 명령을 입력받거나 메시지를 출력하는 등,
인터페이스와 관련된 내용이 이곳에 정의되어 있습니다.
'''
# 사용자로부터 키워드를 입력받는 메뉴입니다.
# base class인 interface_base에는 인터페이스의 종료 결과를
# 표시하는response 변수가 정의되어 있습니다.
# 0: success, 1:fail
class keyword_interface(interface_base):
    ''' 사용자로부터 키워드를 입력받기 위한 CLI 인터페이스입니다.'''
 
    def show_menu(self, file_name):
        '''
        인터페이스 시작점 함수
 
        Args:
            file_name: 검색할 파일 이름
 
        Returns:
            조작이 끝나고 올바른 상태이면 0, 아니면 1을 리턴한다.
 
        Raises:
            정의되지 않음
 
        '''
 
        self.file_info      = id3tag_manip.mp3info(file_name)
        self.keyword_song   = self.file_info[u'song']
        self.keyword_artist = self.file_info[u'artist']
 
        menustring  = u'=' * 20
        menustring += u' 키워드 입력 '
        menustring += u'=' * 20 + u'\n'
        menustring += u'파일: ' + self.file_info[u'path'] + u'\n'
        menustring += u'기본 검색 키워드: \'%s, %s\'\n\n' % (self.keyword_song, self.keyword_artist)
        menustring += u'검색 키워드를 입력...\n'
        menustring += u'(곡명과 아티스트를 쉼표로 구분해 입력. 아티스트는 생략 가능함)\n'
        menustring += u'현재 키워드로 바로 검색하려면 [엔터]를 입력\n'
        menustring += u'[/i]: 파일 정보 보기\n'
        menustring += u'[/q]: 항목 편집 취소\n'
 
        # 영-한 전환이 올바르지 않아도 되도록 처리
        commands  = [u'/i', u'/q', u'/ㅑ', u'/ㅂ', u'*']
        callbacks = [self.show_file_info, self.quit, self.show_file_info, self.quit, self.keyword_input]
 
        while self.menuhandler(menustring, commands, callbacks) == False:
            print u''
 
        return self.__response
 
    def show_file_info(self, args):
        print u'=' * 50
        print u'태그버전: ', self.file_info[u'tag_ver']
        print u'경로:     ', self.file_info[u'path']
        print u'곡명:     ', self.file_info[u'song']
        print u'아티스트: ', self.file_info[u'artist']
        print u'앨범명:   ', self.file_info[u'album']
        print u'발매일:   ', self.file_info[u'release_date']
        print u'시간:     ', self.file_info[u'time']
        print u'VBR:      ', self.file_info[u'vbr']
        print u'Bitrate:  ', self.file_info[u'bit_rate'], u'kbps'
        print u'용량:     ', '%.2f' % (float(self.file_info[u'size_bytes'])/float(1024*1024)), u'MiB'
        print u'디스크:   ', self.file_info[u'disc_num']
        print u'트랙:     ', self.file_info[u'track_num']
        print u'레이블:   ', self.file_info[u'publisher']
        print ''
 
        return False
 
    def quit(self, args):
        ''' 종료 명령 핸들러 '''
 
        self.__response = 1
        return True
 
    def keyword_input(self, str):
        ''' 키워드 입력 핸들러 '''
 
        if str != '':
            keywords = str.split(',')
 
            if len(keywords) == 2:
                self.keyword_song   = keywords[0].strip()
                self.keyword_artist = keywords[1].strip()
            else:
                self.keyword_song   = keywords[0].strip()
                self.keyword_artist = ''
 
        print u'검색 키워드: \'%s, %s\'' % (self.keyword_song, self.keyword_artist)
        print u'키워드가 올바르면 엔터만 눌러주세요. 틀리면 아무 문자를 입력하세요'
 
        r = codeshift.loc2uni(raw_input()).strip()
        if r == u'':
            self.__response = 0
            return  True
        else:
            return False
 
    ''' id3tag_manip.mp3info() 함수로 전달받은 파일 정보 '''
    file_info      = None
 
    ''' 키워드: 노래 이름 '''
    keyword_song   = u''
 
    ''' 키워드: 아티스트 이름 '''
    keyword_artist = u''
 
 
class itemselection_interface(interface_base):
    ''' 검색 결과 중 하나를 선택받기 위한 CLI 인터페이스입니다.'''
 
    def show_menu(self, item_list):
        '''
        메뉴 시작점 함수
 
        Args
            item_list: 곡 정보 검색 결과 (파싱된 콘텐츠) 중 'item' 키의 값.
                       이 값은 리스트로 되어 있다.
 
        Returns
            0: 정상 종료. 다음 단계로 진행해도 좋음.
            1: 사용자로부터 키워드 검색으로 다시 돌아가라는 명령을 받음.
            2: 사용자로부터 쉘로 다시 돌아가라는 명령을 받음.
 
        Raises
            정의되지 않음
        '''
 
        self.__item_list = item_list
 
        menustring =  u'명령 입력...\n'
        menustring += u'[/l]:   결과 다시 출력\n'
        menustring += u'[번호]: 번호의 아이템을 선택\n'
        menustring += u'[/c]:   키워드 검색 단계로 돌아감\n'
        menustring += u'[/q]:   쉘로 돌아가기\n'
 
        commands  = [u'/l', u'/c', u'/q', u'/ㅣ', u'/ㅊ', u'/ㅂ', '*']
        callbacks = [self.show_list, self.cancel, self.quit, self.show_list, self.cancel, self.quit, self.select_item]
 
        self.show_list(None)
 
        while self.menuhandler(menustring, commands, callbacks) == False:
            print u''
 
        return self.__response
 
    def show_list(self, str):
        ''' 검색된 결과를 보여줌 '''
 
        for n in xrange(1, len(self.__item_list)+1):
            self.__print_item(n)
        print ''
        return False
 
    def select_item(self, str):
        ''' 아이템 선택 핸들러 '''
 
        if str.isnumeric() == False:
            print (str+u' is not a number')
            return False
 
        idx = int(str)
        self.__print_item(idx)
 
        confirm = confirm_interface()
        resp = confirm.show_menu()
 
        if resp == 0:
            return False
 
        self.__response   = 0 #success
        self.selnum = idx
        return True
 
    def cancel(self, str):
        ''' 아이템 선택 취소 핸들러 '''
 
        self.__response = 1  # cancel. search by keyword again
        return True
 
    def quit(self, str):
        ''' 쉘 복귀 명령 핸들러 '''
 
        self.__response = 2 # quit to shell
        return True
 
    def __print_item(self, num):
        if 1 <= num and num <= len(self.__item_list):
            song = self.__item_list[num-1]
            print u'=' * 50
            print u'번호:     %02d' % num
            print u'곡명:     %s'   % song[u'title']
            print u'아티스트: %s'   % song[u'artist'][u'name']
            print u'앨범명:   %s'   % song[u'album'][u'title']
            print u''
        else:
            raise Exception(u'printing item: number exceeded the limit')
 
    ''' 리스트 중 사용자가 선택한 번호 '''
    selnum = 0
 
    ''' 곡 정보 검색 결과 (파싱된 콘텐츠) 중 'item' 키의 값. 리스트 자료형이다. '''
    __item_list  = None
 
 
class confirm_interface(interface_base):
    ''' y/n 명령을 입력받기 위한 인터페이스 '''
 
    def show_menu(self):
        '''
        메뉴 시작점 함수
 
        Args
 
        Returns
            1: yes
            0: no
 
        Raises
            정의되지 않음
        '''
 
        menustring = u'확실합니까? (y/n)'
        commands   = [u'y', u'n', u'ㅛ', u'ㅜ']
        callbacks  = [self.yes, self.no, self.yes, self.no]
 
        # 기본 에러메시지 오버라이드.
        self.nocmd_msg = u'y나 n 중 하나만을 선택하세요.'
 
        while self.menuhandler(menustring, commands, callbacks) == False:
            print u''
 
        return self.__response
 
    def yes(self, str):
        self.__response = 1
        return True
 
    def no(self, str):
        self.__response = 0
        return True

쉘 관련 모듈

쉘은 파이썬 표준 라이브러리의 Cmd를 상속받아 구현합니다. 이것을 이용하면 보다 편리하게 쉘을 구현할 수 있습니다. 쉘 클래스 또한 분량이 엄청납니다만, 실제로는 별 것이 없습니다. 대부분이 주석이고 사용자 안내를 위한 텍스트입니다.

우리의 프로그램은 쉘부터 시작하므로, 명령 프롬프트에서는 쉘을 실행시켜야 합니다.

python maniadb_shell.py [인수...]
manidadb_shell.py
# -*- coding: cp949 -*-
import cmd, os, sys
import codeshift
import util
import id3tag_manip
from   maniadb_core import maniadb_core
from   interface    import *
 
class maniadb_shell(cmd.Cmd):
    def emptyline(self):
        pass
 
    def do_load(self, str):
        try:
            loaded_num = self.core.load(str.split())
            print u'%d file(s) loaded.' % loaded_num
 
        except Exception as e:
            util.trace_error(u'Error while loading:', e)
 
    def do_unload(self, str):
        try:
            nums         = self.__parse_param(str)
            unloaded_num = self.core.unload(nums)
            print u'%d file(s) unloaded.' % unloaded_num
 
        except Exception as e:
            util.trace_error(u'Error while unloading:', e)
 
    def do_clear(self, str):
        try:
            self.core.clear_list()
 
        except Exception as e:
            util.trace_error(u'Error while clearing:', e)
 
    def do_list(self, str):
        try:
            if str == u'':
                nums = [x for x in xrange(1, self.core.list_size()+1)]
            else:
                nums = self.__parse_param(str)
 
            totnum = self.core.list_size()
            for num in nums:
                str = u'[%d/%d] ' % (num, totnum)
                if self.core.is_tagged(num) == True:
                    str += u'*'
                str += util.fit_string(self.core.get_file_name(num), 60, u' ... ')
                print str
 
        except Exception as e:
            util.trace_error(u'Error while listing:', e)
 
    def do_det(self, str):
        try:
            num       = int(str)
            file_name = self.core.get_file_name(num)
            file_info = id3tag_manip.mp3info(file_name)
 
            print u'Detailed information of #%d' % num
            print u'=' * 50
            print u'태그버전: ', file_info[u'tag_ver']
            print u'경로:     ', file_info[u'path']
            print u'곡명:     ', file_info[u'song']
            print u'아티스트: ', file_info[u'artist']
            print u'앨범명:   ', file_info[u'album']
            print u'발매일:   ', file_info[u'release_date']
            print u'시간:     ', file_info[u'time']
            print u'VBR:      ', file_info[u'vbr']
            print u'Bitrate:  ', file_info[u'bit_rate'], u'kbps'
            print u'용량:     ', '%.2f' % (float(file_info[u'size_bytes'])/float(1024*1024)), u'MiB'
            print u'디스크:   ', file_info[u'disc_num']
            print u'트랙:     ', file_info[u'track_num']
            print u'레이블:   ', file_info[u'publisher']
            print u''
 
        except Exception as e:
            util.trace_error(u'Error while detailed info:', e)
 
    def do_retr(self, str):
        try:
            nums    = self.__parse_param(str)
            tot_num = self.core.list_size()
            for num in nums:
                print u'[%d / %d]' % (num, tot_num)
                if self.__retrieve(num) == False:
                    print u'Retrieval canceled at %d' % num
                    return False
 
        except Exception as e:
            util.trace_error(u'Error while retrieving:', e)
 
    def do_retrall(self, str):
        try:
            tot_num = self.core.list_size()
            for num in xrange(1, self.core.list_size()+1):
                if self.core.is_tagged(num) == True:
                    print u'Number %d is already tagged.' % num
                    continue
 
                print u'[%d / %d]' % (num, tot_num)
                if self.__retrieve(num) == False:
                    print u'Retrieval canceled at %d' % num
                    return False
 
        except Exception as e:
            util.trace_error(u'Error while retrieving all:', e)
 
    def do_nresp(self, str):
        try:
            if str == u'':
                print self.nresp
            else:
                self.nresp = int(str)
                print u'Set number of responses to', self.nresp
 
        except Exception as e:
            util.trace_error(u'Error while setting nresp:', e)
 
    def do_exp(self, str):
        try:
            if str == '':
                print >> sys.stderr, u'파일 이름을 입력해야 합니다.'
                return False
 
            all_files = self.core.get_all_files()
            with open(str, u'w') as f:
                for one in all_files:
                    f.write(codeshift.uni2loc(one+u'\n'))
            print u'%d item(s) exported to %s' % (len(all_files), str)
 
        except Exception as e:
            util.trace_error(u'Error while exporting all list:', e)
 
    def do_expunt(self, str):
        try:
            if str == '':
                print >> sys.stderr, u'파일 이름을 입력해야 합니다.'
                return False
 
            untagged_files = self.core.get_untagged_files()
            with open(str, u'w') as f:
                for untagged in untagged_files:
                    f.write(codeshift.uni2loc(untagged+u'\n'))
 
            print u'%d untagged item(s) exported to %s' % (len(untagged_files), str)
 
        except Exception as e:
            util.trace_error(u'Error while exporting untagged:', e)
 
    def do_exptag(self, str):
        try:
            if str == '':
                print >> sys.stderr, u'파일 이름을 입력해야 합니다.'
                return False
 
            tagged_files = self.core.get_tagged_files()
            with open(str, u'w') as f:
                for tagged in tagged_files:
                    f.write(codeshift.uni2loc(tagged+u'\n'))
 
            print u'%d tagged item(s) exported to %s' % (len(tagged_files), str)
 
        except Exception as e:
            util.trace_error(u'Error while exporting tagged:', e)
 
    def do_exit(self, str):
        print u'Exiting...'
        sys.exit(0)
 
    def do_tag(self, str):
        try:
            nums  = self.__parse_param(str)
            for num in nums:
                self.core.set_tagged(num, True)
 
        except Exception as e:
            util.trace_error(u'Error while tag command:', e)
 
    def do_unt(self, str):
        try:
            nums  = self.__parse_param(str)
            for num in nums:
                self.core.set_tagged(num, False)
 
        except Exception as e:
            util.trace_error(u'Error while tag command:', e)
 
 
    def do_EOF(self, str):
        return True
 
    # return True: Ok to go to next item
    # return False: Stop right now
    def __retrieve(self, num):
        song_info   = None # song_info
        song_selnum = -1   # chosen item number
        album_info  = None # album_info
 
        # get the item of num
        file_name = self.core.get_file_name(num)
 
        while True:
            # cli interface: keyword input from user
            kwdiface = keyword_interface()
            if kwdiface.show_menu(file_name) != 0:
                print u'Canceled.'
                return False
 
            # send a query and parse the result
            song_info = self.core.song_query(kwdiface.keyword_song, kwdiface.keyword_artist, self.nresp)
 
            # cli interface: choose right one from responses
            itmiface = itemselection_interface()
 
            # itemselection interface response.
            # 0: success, 1: back to keyword search, 2: exit to shell
            response = itmiface.show_menu(song_info[u'item'])
 
            if response == 0:
                song_selnum = itmiface.selnum
                break
 
            elif response == 1:
                continue
 
            elif response == 2:
                return False
 
            else:
                raise Exception(u'Wrong response code at itemselection_interface')
 
        # querying album information
        chosen_item = song_info[u'item'][song_selnum-1]
        album_link  = chosen_item[u'album'][u'link']
        album_id    = album_link[album_link.find(u'a=')+2:]
 
        # album info
        print u'Querying album id %s information...' % album_id
        album_info  = self.core.album_query(album_id)
 
        # update mp3 tag
        self.core.update(num, song_info, song_selnum, album_info)
        print u'성공적으로 태그를 수정했습니다.\n'
        return True
 
    def __parse_param(self, str):
        fin_list = []
 
        for t in str.split():
            # convert t to unicode form
            ut = codeshift.loc2uni(t)
 
            # hyphen position
            hypos = ut.find(u'-')
 
            # we couldn't find hyphen:
            if hypos == -1:
                if ut.isnumeric():
                    intn = int(ut)
                    if 0 < intn and intn <= self.core.list_size():
                        fin_list.append(intn)
                    else:
                        raise Exception(ut+u': Number exceeded the limit')
                else:
                    raise Exception(ut+u' is not a valid numeric form.')
 
            # we've found hyphen,
            else:
                # hyphen is the first character -
                if hypos == 0:
                    endstr = ut[1:]
                    # thoroughly check the number form
                    if endstr.isnumeric():
                        endint = int(endstr)
                        if 0 < endint and endint <= self.core.list_size():
                            # valid number form is converted to a series of numbers from 1 to endint
                            for x in xrange(1, endint+1):
                                fin_list.append(x)
                        else:
                            raise Exception(ut+u': Ending number exceeded the limit')
                    else:
                        raise Exception(ut+u' is not a valid numeric form.')
 
                # hyphen is the last character -
                elif hypos == len(ut)-1:
                    stastr = ut[:hypos]
                    # thoroughly check the number form
                    if stastr.isnumeric():
                        staint = int(stastr)
                        if 0 < staint and staint <= self.core.list_size():
                            # valid number form is converted to a series of numbers from staint to the last of the list
                            for x in xrange(staint, self.core.list_size()+1):
                                fin_list.append(x)
                        else:
                            raise Exception(ut+u': Starting number exceeded the limit')
                    else:
                        raise Exception(ut+u' is not a valid numeric form.')
 
                # hyphen is the middle character
                else:
                    stastr, endstr = ut.split(u'-')
                    if stastr.isnumeric() and endstr.isnumeric():
                        staint, endint = int(stastr), int(endstr)
                        # check integer numbers are all valid
                        if staint > 0 and endint > 0 and staint <= endint and endint <= self.core.list_size():
                            for x in xrange(staint, endint+1):
                                fin_list.append(x)
                        else:
                            raise Exception(ut+u': Starting number or ending number exceeded the limit')
                    else:
                        raise Exception(ut+u' is not a valid numeric form.')
 
        # sort numbers and remove duplication, if any
        fin_list = list(set(fin_list))
        fin_list.sort()
        return fin_list
 
    def help_EOF(self):
        pass
 
    def help_det(self):
        print u'=== det (detailed info) 명령어 도움말 ===\n'
        print u'하나의 인자를 받습니다. 인자는 파일 목록의 번호입니다.'
        print u'목록 번호의 파일을 읽어 세부 정보를 출력합니다.'
        print u'예: det 3'
 
    def help_exp(self):
        print u'=== exp (export all) 명령어 도움말 ===\n'
        print u'하나의 인자를 받습니다. 인자는 파일 이름입니다.'
        print u'지정된 파일에 현재 파일 목록을 기록합니다.'
        print u'예: exp list.txt'
 
    def help_expunt(self):
        print u'=== expunt (exprt untagged) 명령어 도움말 ===\n'
        print u'하나의 인자를 받습니다. 인자는 파일 이름입니다.'
        print u'지정된 파일에 본 프로그램이 현재 변경하지 않은 파일의 목록을 기록합니다.'
        print u'예: expunt untagged.txt'
 
    def help_load(self):
        print u'=== load 명령어 도움말 ===\n'
        print u'한 개 이상의 파일 이름을 인자로 받을 수 있습니다.'
        print u'인자로 직접 MP3 파일 이름을 전달할 수 있고, 파일의 목록을 전달할 수도 있습니다.'
        print u'파일의 목록은 한 줄에 하나씩 MP3 파일을 적은 텍스트 파일입니다.'
        print u'파일 목록은 파일 이름 앞에 \'@\'기호를 앞에 붙여서 구분합니다.'
        print u'예: load a.mp3 b.mp3 @othrs.txt'
        print u'    [othrs.txt 내용]'
        print u'     c.mp3'
        print u'     d.mp3'
        print u'     e.mp3'
        print u''
        print u'    * 로드되는 파일 목록: a~e.mp3'
 
    def help_retr(self):
        print u'=== retr (retrieve) 명령어 도움말 ===\n'
        print u'maniadb.com에 mp3 태그 정보를 검색합니다.'
        print u'파일 목록 번호를 인자로 받습니다. 각 번호는 공백으로 구분합니다.'
        print u'중복된 번호는 한 번만 처리되며 \'-\'기호를 이용하여 연속된 번호를 입력할 수 있습니다'
        print u'단, 시작 번호와 끝 번호, 그리고 \'-\'기호 사이에는 공백이 없어야 합니다.'
        print u'\'-\'기호를 쓸 때 시작번호나 끝 번호 중 하나를 생략할 수 있는데,\n이 때 생략된 번호는 각 목록의 처음과 마지막 번호로 간주합니다.'
        print u'예: retr 1 2 5-8 (목록 번호 1, 2, 5, 6, 7, 8번의 태그 정보를 검색합니다.)'
        print u'    retr -4 (목록 번호 1, 2, 3, 4번의 태그 정보를 검색합니다.)'
        print u'    retr 11- (목록 번호 11번부터 마지막 번호까지 검색합니다.)'
 
    def help_tag(self):
        print u'=== tag (set tagged) 명령어 도움말 ===\n'
        print u'목록 번호의 MP3 파일의 태그를 현재 프로그램에서 수정한 것으로 처리합니다.'
        print u'파일 목록의 번호를 인자로 받습니다. \'-\'기호를 이용하여 연속된 번호를 입력할 수 있습니다'
        print u'태그는 수정한 것으로 처리되지만, 실제로 내용이 변경된 것은 아닙니다.'
        print u'예: tag 1 7 22 (1, 7, 22번 MP3 파일에 대해 태그를 수정한 것으로 처리.)'
        print u'    tag 1-20 (1~20번 MP3 파일에 대해 태그를 수정한 것으로 처리.)'
        print u'    tag -5 (1~5번 파일에 대해 처리.)'
        print u'    tag 10- (10~마지막 번호까지 처리.)'
 
    def help_unt(self):
        print u'=== unt (set untagged) 명령어 도움말 ===\n'
        print u'목록 번호의 MP3 파일의 태그를 현재 프로그램에서 아직 수정하지 않은 것으로 처리합니다.'
        print u'파일 목록의 번호를 인자로 받습니다. \'-\'기호를 이용하여 연속된 번호를 입력할 수 있습니다'
        print u'태그는 수정하지 않은 것으로 처리되지만, 실제로 내용이 변경된 것은 아닙니다.'
        print u'예: unt 1 7 22 (1, 7, 22번 MP3 파일에 대해 태그를 수정하지 않은 것으로 처리.)'
        print u'    unt 1-20 (1~20번 MP3 파일에 대해 태그를 수정하지 않은 것으로 처리.)'
        print u'    unt -5 (1~5번 파일에 대해 처리.)'
        print u'    unt 10- (10~마지막 번호까지 처리.)'
 
    def help_clear(self):
        print u'=== clear 명령어 도움말 ===\n'
        print u'목록을 초기화합니다. 목록 내용이 모두 지워집니다. 인자를 필요로 하지 않습니다.'
        print u'예: clear'
 
    def help_exit(self):
        print u'=== help_exit 명령어 도움말 ===\n'
        print u'프로그램을 종료합니다. 인자를 필요로 하지 않습니다.'
        print u'예: exit'
 
    def help_exptag(self):
        print u'=== exptag (export tagged) 명령어 도움말 ===\n'
        print u'현재 프로그램이 태그를 수정한 파일만을 목록으로 만듭니다.'
        print u'파일 이름 하나를 인자로 받습니다. 해당 파일에 한 줄에 하나씩 목록의 파일 경로를 적습니다.'
        print u'예: exptag tagged.txt'
 
    def help_nresp(self):
        print u'=== nresp (number of responses) 명령어 도움말 ===\n'
        print u'검색 쿼리를 보낼 때 최대 몇 개까지 응답을 받을 수 있을지 결정합니다.'
        print u'기본값은 10이며 Maniadb OpenAPI v0.3에 따르면 최대 200까지 가능합니다.'
        print u'인자 없이 입력하면 현재 설정값을 출력하고, 인자를 입력하면 설정값을 입력된 값으로 변경합니다.'
        print u'예: nresp (현재 설정값 출력)'
        print u'    nresp 50 (최대 50개까지 검색 응답을 받을 수 있도록 조정)'
 
    def help_retrall(self):
        print u'=== retrall (retrieve all) 명령어 도움말 ===\n'
        print u'모든 파일 목록에 대해 태그 검색을 수행합니다. 인자를 필요로 하지 않습니다.'
        print u'예: retrall'
 
    def help_unload(self):
        print u'=== unload 명령어 도움말 ===\n'
        print u'load 명령과 반대로 목록에서 내용을 삭제합니다.'
        print u'인자로 목록 번호를 받습니다. \'-\'기호를 이용하여 연속된 번호를 입력할 수 있습니다'
        print u'실제로 파일이 디스크에서 삭제되지는 않습니다.'
        print u'예: unload 3 (3번을 목록에서 삭제)'
        print u'    unload 11-20 (11~20번을 목록에서 삭제)'
        print u'    unload 7 9 11 20-22 (7, 9, 11, 20, 21, 22번을 목록에서 삭제)'
 
    def help_list(self):
        print u'=== list 명령어 도움말 ===\n'
        print u'현재 파일 목록을 출력합니다.'
        print u'인자를 입력하지 않을 경우 모든 파일 목록을 출력합니다.'
        print u'인자로 목록 번호를 전달할 수 있습니다.  \'-\'기호를 이용하여 연속된 번호를 입력할 수 있습니다'
        print u'현재 프로그램에 의해 태그가 수정된 파일은 파일 경로 앞에 \'*\' 가 붙습니다.'
        print u'예: list -10 (1~10번을 출력)'
        print u'    list 100- (100번부터 마지막까지 출력)'
        print u'    list 5 8 11 16-30 (5, 8, 11, 16~30번을 출력)'
 
    core  = maniadb_core()
    nresp = 10
 
def help():
    print u'A Simple Tag Retreival Agent, powered by maniadb.com\n'
    print u'사용법: python maniadb_shell.py arguments...\n'
    print u'arguments'
    print u'\t파일 목록. 파일 목록은 하나씩 인자로 전달해도 되고, 텍스트 파일 안에 한 줄에 하나씩 입력해도 된다.'
    print u'\t텍스트 파일을 인자로 줄 때 \'@\' 기호룰 붙인다.'
 
def main(argv):
    if len(argv) == 2 and argv[1] == u'-h':
        help()
        return 0
    else:
        try:
            shell = maniadb_shell()
 
            if len(argv) > 1: shell.core.load(argv[1:])
            shell.doc_header = codeshift.uni2loc(u'명령어의 목록입니다. (자세한 사항은 \'help <명령어>\'을 입력)')
            shell.nohelp     = codeshift.uni2loc(u'명령어 \'%s\'에 대한 도움말이 없습니다.')
            shell.prompt     = codeshift.uni2loc(u'maniadb>')
            shell.cmdloop(codeshift.uni2loc(u'=== A Simple Tag Retreival Agent, powered by maniadb.com ==='))
 
        except KeyboardInterrupt:
            print u'Exiting...'
 
    return 0
 
if __name__ == '__main__':
    sys.exit(main(sys.argv))

코어 모듈

쉘이 명령하고자 하는 바를 쉘 안에 모두 구현해버리면 프로그램도 복잡해지고, 나중에 인터페이스를 GUI 등으로 변경하고자 할 때 상당히 변경하기도 어려워집니다. 그러므로 쉘 모듈과 코어 모듈을 분리해서 명령은 쉘에서 처리하고, 실제 작업은 코어 모듈이 담당하도록 하였습니다.

maniadb_core.py
# -*- coding: cp949 -*-
import os, time
import codeshift
import id3tag_manip
from   api_album_v3    import api_album_v3
from   api_search_v3   import api_search_v3
from   parse_album_v3  import parse_album_v3
from   parse_search_v3 import parse_search_v3
 
class data_elem:
    ''' 파일 목록 정보를 정의하는 클래스 '''
 
    @property
    def modified(self):
        return self.__modified
 
    @modified.setter
    def modified(self, flag):
        self.__modified = flag
 
    @property
    def file_name(self):
        return self.__file_name
 
    @file_name.setter
    def file_name(self, name):
        return self.__file_name
 
    __file_name = u''
    __modified  = False
 
 
class maniadb_core:
    '''
    maniadb 태그 검색 에이전트의 코어 기능을 정의한 클래스.
    모든 작업은 코어 클래스를 통해서만 가능하다.
    '''
 
    def list_size(self):
        ''' 현재 파일 목록의 개수를 리턴한다. '''
 
        return len(self.__data_list)
 
    def load(self, args):
        '''
        파일 목록을 불러온다.
 
        Args
            args: 파일 목록이 담긴 리스트. 리스트의 원소는 두 가지 타입이 있다.
                  하나는 단순 파일 이름, 다름 하나는 파일의 목록을 담은 텍스트 파일이다.
                  전자의 경우 단순히 MP3 파일 이름 문자열이다.
                  두 번째 또한 파일 이름의 문자열이지만, 문자열은 MP3 파일 경로가 아닌
                  텍스트 파일이다. 이 텍스트 파일 안에 MP3 파일의 경로가 한 줄에 하나씩
                  나열되어 있다.
                  만일 인자로 전달할 파일 이름이 후자의 형태인 경우, 파일 이름 앞에 반드시
                  '@' 기호를 붙여야 한다.
 
        Returns
            읽어들인 목록의 개수
 
        Raises
            일반 예외: 목록으로 전달된 MP3 파일의 경로가 올바르지 않는 경우 발생된다.
                       하나라도 올바르지 않은 경로가 존재하면 명령은 전부 무시된다.
 
        '''
 
        file_list = []
 
        # filename checking
        for arg in args:
            if arg[0] == u'@':
                with open(arg[1:], u'r') as f:
                    for line in f.xreadlines():
                        path = os.path.normpath(os.path.abspath(line.strip()))
                        if os.path.exists(path):
                            file_list.append(path)
                        else:
                            raise Exception(path+u' doesn\'t exitst')
            else:
                path = os.path.normpath(os.path.abspath(arg))
                if os.path.exists(path):
                    file_list.append(path)
                else:
                    raise Exception(path+u' doesn\'t exitst')
 
        for f in file_list:
            buf = data_elem()
            buf.file_name = codeshift.loc2uni(f)
            buf.modified  = False
            self.__data_list.append(buf)
 
        return len(file_list)
 
    def unload(self, nums):
        '''
        입력한 번호에 해당하는 엔트리를 파일 목록에서 지운다.
 
        Args
            nums: 지우려는 목록의 번호. 번호는 항상 1부터 시작한다.
 
        Returns
            지워진 목록의 개수
 
        Raises
            일반 예외. 번호가 목록 범위를 벗어날 경우 발생한다.
 
        '''
 
        for num in nums:
            if num < 1 or num > self.list_size():
                raise Exception(u'Number exceeded the limit')
 
        for off, num in enumerate(nums):
            idx = num-off-1
            del self.__data_list[idx]
 
        return len(nums)
 
    def clear_list(self):
        ''' 파일 목록을 완전히 지운다 '''
 
        self.__data_list[:] = []
 
    def song_query(self, keyword_song, keyword_artist, nresp):
        '''
        노래 검색 쿼리를 보내어 결과를 수신한다.
        연결에 실패한 경우 3회까지 재시도한다.
 
        Args
            keyword_song:   노래 검색어
            keyword_artist: 가수(아티스트) 검색어
            nresp:          전달받을 응답의 최대 개수
 
        Returns
            Maniadb 응답. 파싱된 콘텐츠 형태로 리턴된다.
            파싱된 콘텐츠란 서버로부터 받은 XML의 구조를 파이썬 자료형인
            딕셔너리와 리스트로 표현한 것을 말한다.
 
        Raises
            일반 예외. 3회 연결 실패의 경우 발생한다.
 
        '''
        # throwing query
        api = api_search_v3()
 
        # if some problem happens, xmldoc should be None
        retry  = 3
        xmldoc = None
        while True:
            if keyword_artist != u'':
                xmldoc = api.search(u'song', u'song', keyword_song, u'artist', keyword_artist, nresp)
            else:
                xmldoc = api.search(u'song', u'song', keyword_song, u'', u'', nresp)
 
            if xmldoc == None:
                if retry > 0:
                    print u'Song info search failed. Retrying...', retry
                    retry -= 1
                    time.sleep(3)
                    continue
                else:
                    raise Exception('Failed to connect to maniadb.com')
            else:
                break
 
        # write xml file to debug.
        with open(u'search_result.xml', u'w') as f:
            f.write(xmldoc)
 
        return parse_search_v3(u'song', xmldoc).result
 
    def album_query(self, album_id):
        '''
        앨범 상세 정보를 수신받는다.
        연결에 실패한 경우 3회까지 재시도한다.
 
        Args
            album_id: Maniadb 내부 고유 앨범 ID
 
        Returns
            Maniadb 응답. 파싱된 콘텐츠 형태로 리턴된다.
            파싱된 콘텐츠란 서버로부터 받은 XML의 구조를 파이썬 자료형인
            딕셔너리와 리스트로 표현한 것을 말한다.
 
        Raises
            일반 예외. 3회 연결 실패의 경우 발생한다.
 
        '''
 
        api    = api_album_v3()
        retry  = 3
        xmldoc = None
        while True:
            xmldoc = api.search(album_id)
 
            if xmldoc == None:
                if retry > 0:
                    print u'Album id search failed. Retrying...', retry
                    retry -= 1
                    time.sleep(3)
                    continue
                else:
                    raise Exception('Failed to connect to maniadb.com')
            else:
                break
 
        # debug
        with open(u'album_result.xml', u'w') as f:
            f.write(xmldoc)
 
        return parse_album_v3(xmldoc).result
 
    def get_all_files(self):
        ''' 모든 파일 이름 목록을 리턴한다. '''
 
        all_files = []
        for item in self.__data_list:
            all_files.append(item.file_name)
        return all_files
 
    def get_tagged_files(self):
        ''' 전체 파일 목록 중 현재 프로그램이 태그를 변경한 것들만 골라 그 이름의 목록을 리턴한다. '''
 
        tagged = []
        for item in self.__data_list:
            if item.modified == True:
                tagged.append(item.file_name)
        return tagged
 
    def get_untagged_files(self):
        ''' 전체 파일 목록 중 현재 프로그램이 변경하지 않은 것들만을 골라 그 이름의 목록을 리턴한다. '''
 
        untagged = []
        for item in self.__data_list:
            if item.modified == False:
                untagged.append(item.file_name)
        return untagged
 
    def get_file_name(self, num):
        ''' 파일 목록 번호에 해당하는 엔트리의 파일 이름을 리턴한다. '''
 
        if 1 <= num and num <= self.list_size():
            return self.__data_list[num-1].file_name
        else:
            raise Exception(u'number exceeded the limit')
 
    def is_tagged(self, num):
        ''' 파일 목록 번호에 해당하는 엔트리의 태그 내용이 현재 프로그램에 의해 수정되었는지를 리턴한다. '''
 
        if 1 <= num and num <= self.list_size():
            return self.__data_list[num-1].modified
        else:
            raise Exception(u'number exceeded the limit')
 
    def set_tagged(self, num, modified):
        ''' 파일 목록 번호에 해당하는 엔트리의 태그 내용 수정 여부를 조절한다. '''
 
        if 1 <= num and num <= self.list_size():
            self.__data_list[num-1].modified = modified
        else:
            raise Exception(u'number exceeded the limit')
 
    def update(self, listnum, song_info, song_selnum, album_info):
        '''
        MP3 파일 정보를 업데이트한다.
 
        Args
            listnum:     업데이트할 파일 목록 번호
            song_info:   노래 정보 검색 결과 (파싱된 콘텐츠)
            song_selnum: 노래 정보 중 취할 결과 번호
            album_info:  앨범 정보 검색 결과 (파싱된 콘텐츠)
 
        Returns
            None
 
        Raises
            None
 
        '''
 
        target_file_name = self.get_file_name(listnum)
        id3tag_manip.update_mp3_tag(target_file_name, song_info, song_selnum-1, album_info)
        self.set_tagged(listnum, True)
 
    ''' 파일 목록. data_elem 의 리스트이다. '''
    __data_list = []

기타 모듈

명령 프롬프트에 긴 경로의 파일을 줄여 주기, 에러 메시지 처리 등 잡다한 역할을 하는 모듈을 따로 만들었습니다.

util.py
# -*- coding: cp949 -*-
import traceback, os.path, sys
 
# fit a long text string to length
def fit_string(input, length, omit_str = u'...'):
    input_len = len(input)
 
    if input_len <= length:
        return input
 
    omit_len = len(omit_str)
    lpos     = (length-omit_len)/2
 
    if (length-omit_len)%2 == 1:
        lpos += 1
 
    rpos = input_len-lpos+1
 
    return input[:lpos] + omit_str + input[rpos:]
 
# 빡세게 에러 출력
MANIADB_DETAILED_TRACE = False
 
def trace_error(errmsg, err):
    if MANIADB_DETAILED_TRACE == True:
        exc_type, exc_obj, exc_tb = sys.exc_info()
        file_name = os.path.basename(exc_tb.tb_frame.f_code.co_filename)
 
        print >> sys.stderr, u'%s %s' % (errmsg, err)
        print >> sys.stderr, u''
        print >> sys.stderr, u'Exception at %s line %d, type %s' % (file_name, exc_tb.tb_lineno, exc_type.__name__)
        print >> sys.stderr, u''
        print >> sys.stderr, u'Call Stack:'
        for item in traceback.extract_stack():
            print >> sys.stderr, u'> File: %s, line: %d, function: %s, code: %s' % (item[0], item[1], item[2], item[3])
 
    else:
        print >> sys.stderr, u'%s %s' % (errmsg, err)
 
 
if __name__ == '__main__':
    print fit_string(u'a'*90, 80)

바이너리 (exe) 파일로 만들기

처음에는 장난감처럼 만들어 보려고 했는데, 만들다 보니 장난감이 꽤 묵직한 도구 처럼 커져 버렸습니다. 초라한 콘솔 툴이지만 OpenAPI 검색이나 태그 수정등이 나름 상당히 편리합니다. 작은 콘솔 프로그램으로 쓸만하게 된 것 같습니다.

그래서 파이썬 코드를 실행 가능한 바이너리로 한 번 만들어 보려고 합니다. 이렇게 하려면 우선은 py2exe라는 패키지가 필요합니다. py2exe 홈페이지에서 파이썬 버전에 맞는 패키지를 다운로드 받아 설치합니다.

파이썬 코드를 실행 가능한 파일로 만들기 위해 다음 소스를 추가적으로 작성합니다.

to_binary.py
# -*- coding: cp949 -*-
from distutils.core import setup
import py2exe
 
setup(console=['maniadb_shell.py'])

명령 프롬프트에서 다음과 같이 실행하면 바이너리 파일이 생성됩니다.

python to_binary.py py2exe

별 문제가 없다면 'build', 'dist' 두 디렉토리가 생성된 것을 확인할 수 있을 것입니다. 배포할 때는 'dist' 디렉토리만 배포하시면 됩니다.

마치며

Maniadb OpenAPI를 이용하여 간단한 MP3 파일 태그 검색, 수정 프로그램을 만들어 보았습니다. OpenAPI 가 전달하는 XML 문서를 파싱하여 원하는 정보를 추출하는 것이 프로그램의 거의 전부입니다만, 프로그램을 보다 편리하게 만들기 위해 간단하지만 CLI 인터페이스도 구현하였습니다.

아주 간단한 CLI 프로그램이지만 어떤가요? 꽤 코드가 많지요? GUI였다면 이것의 배는 되었을 겁니다. 사실 인터페이스는 싹 제거하고 핵심 코드만 짜면 그렇게까지 많지 않았을 겁니다. 인터페이스가 훌륭한 프로그램을 만드는 것이란 나름 중노동이란 사실을 알아 주셨으면 합니다. 한편 우리의 장난감 말고 세상의 다른 멋진 프로그램들을 한 번 보세요. 얼마나 많은 노력이 들어갔는지 한 번 생각해 보셨으면 하는 마음입니다.

사실 인터페이스 부분은 조금 과한 면도 있었습니다. 본 문서에서는 쿼리 전달, XML 파싱, ID3태그 수정만 언급하고 더 깊게 들어가지 않아도 되었다고 생각합니다. 그러나 과정이 있었기에 결과적으로 (여전히 장난감이긴 하지만) 그나마 이 정도로 이용해 볼만한 태그 검색 프로그램이 완성된 듯한 느낌이 듭니다.

그리고 py2exe를 이용해 바이너리 파일도 만들어 보았습니다. 이렇게 하면 파이썬을 설치하지 않은 pc에도 우리가 작성한 프로그램을 실행할 수 있습니다.

저는 이 프로그램을 이용하여 약 250개의 가요 MP3파일에 대해 태그 검색을 적접 실험해 보았습니다. 아주 애매한 경우를 제외하고는 검색도 상당히 양호하였고 태그 또한 괜찮게 수정된 것을 보았습니다.

하지만 Maniadb 서버에서도 가끔 잘못된 정보가 전달되는 경우를 몇 번 경험하였습니다. 그리고 프로그램 자체가 아주 완성도가 높은 것은 아닙니다. 이 나머지는 이 문서를 읽는 분들께 맡깁니다.

project/mp3tagretrieval.txt · 마지막으로 수정됨: 2014/10/09 21:24 저자 127.0.0.1

Donate Powered by PHP Valid HTML5 Valid CSS Driven by DokuWiki