ID3v1 Reader

id3v1reader.png

Screenshot of the Tkinter GUI (featuring music files by paniq and jco).

#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
ID3v1 Reader
============

An ID3v1_ tag reader for MP3 files with a graphical frontend.

.. _ID3v1: http://www.id3.org/ID3v1

:Copyright: 2004-2008 Jochen Kupperschmidt
:Date: 18-Nov-2008 (previous release: 29-Sep-2004)
:License: GNU General Public License
"""

from __future__ import with_statement
from glob import iglob
from itertools import islice
from operator import itemgetter
import os
import Tkinter as tk
import tkFileDialog


# ID3v1 stuff

FIELDS = (
    ('title', 30),
    ('artist', 30),
    ('album', 30),
    ('year', 4),
    ('comment', 30),
    ('genre', 1)
)
GENRES = (
    # The following genres (0-79) are defined in ID3v1:
    'Blues', 'Classic Rock', 'Country', 'Dance', 'Disco', 'Funk', 'Grunge',
    'Hip-Hop', 'Jazz', 'Metal', 'New Age', 'Oldies', 'Other', 'Pop', 'R&B',
    'Rap', 'Reggae', 'Rock', 'Techno', 'Industrial', 'Alternative', 'Ska',
    'Death Metal', 'Pranks', 'Soundtrack', 'Euro-Techno', 'Ambient',
    'Trip-Hop', 'Vocal', 'Jazz+Funk', 'Fusion', 'Trance', 'Classical',
    'Instrumental', 'Acid', 'House', 'Game', 'Sound Clip', 'Gospel', 'Noise',
    'Alternative Rock', 'Bass', 'Soul', 'Punk', 'Space', 'Meditative',
    'Instrumental Pop', 'Instrumental Rock', 'Ethnic', 'Gothic', 'Darkwave',
    'Techno-Industrial', 'Electronic', 'Pop-Folk', 'Eurodance', 'Dream',
    'Southern Rock', 'Comedy', 'Cult', 'Gangsta', 'Top 40', 'Christian Rap',
    'Pop/Funk', 'Jungle', 'Native US', 'Cabaret', 'New Wave', 'Psychadelic',
    'Rave', 'Showtunes', 'Trailer', 'Lo-Fi', 'Tribal', 'Acid Punk',
    'Acid Jazz', 'Polka', 'Retro', 'Musical', 'Rock & Roll', 'Hard Rock',
    # The following genres (80-147) are Winamp extensions
    # (or at least are 80-125):
    'Folk', 'Folk-Rock', 'National Folk', 'Swing', 'Fast Fusion', 'Bebob',
    'Latin', 'Revival', 'Celtic', 'Bluegrass', 'Avantgarde', 'Gothic Rock',
    'Progressive Rock', 'Psychedelic Rock', 'Symphonic Rock', 'Slow Rock',
    'Big Band', 'Chorus', 'Easy Listening', 'Acoustic', 'Humour', 'Speech',
    'Chanson', 'Opera', 'Chamber Music', 'Sonata', 'Symphony', 'Booty Bass',
    'Primus', 'Porn Groove', 'Satire', 'Slow Jam', 'Club', 'Tango', 'Samba',
    'Folklore', 'Ballad', 'Power Ballad', 'Rhytmic Soul', 'Freestyle', 'Duet',
    'Punk Rock', 'Drum Solo', 'Acapella', 'Euro-House', 'Dance Hall', 'Goa',
    'Drum & Bass', 'Club-House', 'Hardcore', 'Terror', 'Indie', 'BritPop',
    'Negerpunk', 'Polsk Punk', 'Beat', 'Christian Gangsta', 'Heavy Metal',
    'Black Metal', 'Crossover', 'Contemporary C', 'Christian Rock',
    'Merengue', 'Salsa', 'Trash Metal', 'Anime', 'JPop', 'SynthPop'
    )

def read_tag_data(filename):
    """Read the last 128 bytes from the file."""
    with open(filename, 'rb') as f:
        f.seek(-128, os.SEEK_END)
        return f.read()

def parse_tag(data):
    """Parse data into an ID3v1 tag."""
    chars = iter(data)
    for name, length in FIELDS[:-1]:
        yield name, ''.join(char
            for char in islice(chars, length) if char != '\x00')

    # Genre is the last field.
    try:
        yield 'genre', GENRES[ord(data[-1])]
    except IndexError:
        pass

def load_tag(filename):
    """Load a file's tag into a dictionary."""
    # Create dictionary with empty strings for defaults.
    d = dict.fromkeys(map(itemgetter(0), FIELDS), '')
    try:
        data = read_tag_data(filename)
    except IOError:
        # Error reading the file.
        pass
    else:
        if data.startswith('TAG'):
            # Update the dictionary with tag key/value pairs.
            # Skip the data's ``TAG`` prefix.
            d.update(parse_tag(data[3:]))
    return d


# Tkinter GUI stuff

class FileBrowser(tk.Frame):
    """A path selector and current directory browser."""

    def __init__(self, *args, **kwargs):
        tk.Frame.__init__(self, *args, **kwargs)

        self.path = tk.StringVar()
        # Begin with the current directory.
        self.path.set(os.getcwd())

        tk.Label(self, text='Path:').grid(row=0, column=0)
        tk.Entry(self, textvariable=self.path,
            state=tk.DISABLED, disabledforeground='Black') \
            .grid(row=0, column=1, sticky=tk.W + tk.E)
        tk.Button(self, text='Browse', command=self.change_dir) \
            .grid(row=0, column=2)

        self.files = ScrollableListbox(self)
        self.files.grid(row=1, column=0, columnspan=3,
                        sticky=tk.N + tk.S + tk.W + tk.E)

        self.columnconfigure(1, weight=1)
        self.rowconfigure(1, weight=1)

        self.change_dir(self.path.get())

    def get_filename(self):
        """Return the current path and filename."""
        return os.path.join(self.path.get(), self.files.list.get(tk.ACTIVE))

    def change_dir(self, dir=None):
        """Change directory and update file list."""
        if dir is None:
            dir = tkFileDialog.askdirectory()
        if not dir:
            # User might have chosen to 'abort'.
            return
        self.path.set(os.path.abspath(dir))
        self.files.update(self.path.get())


class ScrollableListbox(tk.Frame):
    """A list box with horizontal and vertical scrollbars."""

    def __init__(self, *args, **kwargs):
        tk.Frame.__init__(self, *args, **kwargs)

        # Create listbox for files with scrollbars.
        self.list = tk.Listbox(self)
        sb_x = tk.Scrollbar(self, orient=tk.HORIZONTAL, command=self.list.xview)
        sb_y = tk.Scrollbar(self, orient=tk.VERTICAL, command=self.list.yview)
        self.list.config(xscrollcommand=sb_x.set, yscrollcommand=sb_y.set)

        # Set up layout.
        self.list.grid(row=0, column=0, sticky=tk.N + tk.S + tk.W + tk.E)
        sb_x.grid(row=1, column=0, sticky=tk.S + tk.W + tk.E)
        sb_y.grid(row=0, column=1, rowspan=2, sticky=tk.N + tk.S + tk.E)
        self.rowconfigure(0, weight=1)
        self.columnconfigure(0, weight=1)

    def update(self, path, file_mask='*.mp3'):
        """Empty and update list with files from ``path``."""
        self.list.delete(0, tk.END)
        for filename in iglob(os.path.join(path, file_mask)):
            self.list.insert(tk.END, os.path.basename(filename))


class TagDetails(tk.Frame):
    """A grid of tag attributes."""

    field_names = ('filename', 'title', 'artist', 'album', 'year', 'comment',
        'genre')

    def __init__(self, *args, **kwargs):
        tk.Frame.__init__(self, *args, **kwargs)

        self.tag = {}
        for row, field in enumerate(self.field_names):
            self.tag[field] = tk.StringVar()
            tk.Label(self, text=field.capitalize() + ':') \
                .grid(row=row, column=0, sticky=tk.W)
            tk.Entry(self, textvariable=self.tag[field], state=tk.DISABLED,
                     disabledforeground='Black') \
                .grid(row=row, column=1, sticky=tk.W + tk.E)
        self.columnconfigure(1, weight=1)

    def update(self, filename):
        """Update the shown tag attributes."""
        tag = load_tag(filename)
        tag['filename'] = os.path.basename(filename)
        for key, value in tag.iteritems():
            self.tag[key].set(value)


class GUI(tk.Tk):
    """A graphical frontend for the ID3v1 tag reader."""

    def __init__(self):
        """Create and lay out widgets."""
        tk.Tk.__init__(self)
        self.title('ID3v1 Reader')

        self.fb = FileBrowser(self)
        self.fb.grid(row=0, column=0, sticky=tk.N + tk.S + tk.W + tk.E)

        self.tag = TagDetails(self)
        self.tag.grid(row=1, column=0, sticky=tk.W + tk.E)

        self.rowconfigure(0, weight=1)
        self.columnconfigure(0, weight=1)

        # Bind selection changes to trigger tag display updates.
        self.fb.files.list.bind('<Double-Button-1>', self.update_tag)

    def update_tag(self, event):
        """Update shown tag information."""
        self.tag.update(self.fb.get_filename())


if __name__ == '__main__':
    GUI().mainloop()