playlistgen.py with syntax coloring

This HTML file was generated with Kalle's syntaxcolor.py


   1"""
   2Read playlist exported from iTunes, generate HTML file.
   3By Kalle (http://qalle.net)
   4"""
   5
   6import sys
   7import os.path
   8import time
   9
  10# source file settings
  11SOURCE_ENCODING = "utf-16le"
  12SOURCE_FIELD_SEPARATOR = "\t"
  13SOURCE_COLUMN_NUMBERS = {
  14    "name":        0,   # song, e.g. "Never Gonna Give You Up"
  15    "artist":      1,   # e.g. "Rick Astley"
  16    "album":       3,   # e.g. "The Ultimate 80s"
  17    "time":        11,  # length in seconds, e.g. "209"
  18    "discNumber":  12,  # e.g. "1"
  19    "trackNumber": 14,  # e.g. "1"
  20    "comments":    24,  # e.g. "awesome"
  21}
  22
  23# target file settings
  24TARGET_ENCODING = "utf-8"
  25
  26# used in album numbers
  27SMALL_NUMBERS = {
  28    1: "one",
  29    2: "two",
  30    3: "three",
  31    4: "four",
  32    5: "five",
  33    6: "six",
  34    7: "seven",
  35    8: "eight",
  36    9: "nine",
  37    10: "ten",
  38    11: "eleven",
  39    12: "twelve",
  40    13: "thirteen",
  41    14: "fourteen",
  42    15: "fifteen",
  43    16: "sixteen",
  44    17: "seventeen",
  45    18: "eighteen",
  46    19: "nineteen",
  47    20: "twenty",
  48}
  49
  50HTML_SPECIAL_CHARS = {
  51    ord("&"): "&",
  52    ord("<"): "&lt;",
  53    ord(">"): "&gt;",
  54}
  55
  56HELP_TEXT = """\
  57Reads a playlist exported from iTunes (usually Music.txt) and generates an HTML
  58file.
  59
  60Args: InputFile OutputFile
  61    InputFile: playlist file to read
  62    OutputFile: HTML file to (over)write
  63
  64If you get errors related to the format of the input file or the output file
  65looks weird: edit the "SOURCE FILE SETTINGS" near the start of this script.\
  66"""
  67
  68# initial HTML
  69HTML_START = """\
  70<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN""http://www.w3.org/TR/html4/strict.dtd">
  71<html>
  72<head>
  73<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  74<title>My playlist</title>
  75<style type="text/css">
  76
  77* { background:black; color:white; font-family:serif; }
  78h1, h2 { font-family:sans-serif; }
  79
  80ul#songs { padding-left:0; }
  81ul#songs li { color:cyan; font-weight:bold; list-style-type:none; }
  82ul#songs ul { padding-left:2em; margin-bottom:1em; }
  83ul#songs ul li { color:magenta; font-weight:bold; font-style:italic; }
  84ul#songs ul ul { margin-bottom:.5em; }
  85ul#songs ul ul li { color:white; font-weight:normal; font-style:normal; }
  86ul#songs ul ul ul { margin-bottom: 0; }
  87ul#songs ul ul ul li { color:yellow; }
  88
  89/* to make the HTML code shorter, the following are used instead of span */
  90tt { font-family:monospace; white-space:pre-wrap; }  /* track number */
  91i { color:silver; }  /* comments */
  92b { color:gray; font-weight:normal; }  /* length */
  93
  94</style>
  95</head>
  96<body>
  97<h1>My playlist</h1>
  98"""
  99
 100# final HTML
 101HTML_END = """
 102<hr>
 103<p><a href="/">Back to front page</a></p>
 104</body>
 105</html>\
 106"""
 107
 108def print_error(message, lineNum):
 109    exit("Error: {:s} on line {:d}".format(message, lineNum))
 110
 111def read_songs(hnd):
 112    """Read source file. Return important info as list of dicts."""
 113
 114    # skip first line
 115    hnd.seek(0)
 116    next(hnd)
 117
 118    # list of songs as dicts
 119    songs = []
 120
 121    for (lineNum, line) in enumerate(hnd):
 122        # split line to fields
 123        fields = line.strip().split(SOURCE_FIELD_SEPARATOR)
 124
 125        # copy important fields to variables (see first line of playlist file)
 126        try:
 127            name        = fields[SOURCE_COLUMN_NUMBERS["name"]]
 128            artist      = fields[SOURCE_COLUMN_NUMBERS["artist"]]
 129            album       = fields[SOURCE_COLUMN_NUMBERS["album"]]
 130            time_       = fields[SOURCE_COLUMN_NUMBERS["time"]]
 131            discNumber  = fields[SOURCE_COLUMN_NUMBERS["discNumber"]]
 132            trackNumber = fields[SOURCE_COLUMN_NUMBERS["trackNumber"]]
 133            comments    = fields[SOURCE_COLUMN_NUMBERS["comments"]]
 134        except IndexError:
 135            exit("Error: too few columns on line {:d}, exiting.".format(
 136                lineNum + 2
 137            ))
 138
 139        # mark empty name, artist & album as unknown
 140        if name == "":
 141            name = "(unknown song)"
 142        if artist == "":
 143            artist = "(unknown artist)"
 144        if album == "":
 145            album = "(unknown album)"
 146
 147        # length must be a non-negative integer
 148        try:
 149            time_ = int(time_)
 150            if time_ < 0:
 151                raise ValueError
 152        except ValueError:
 153            print_error("invalid song length", lineNum + 2)
 154
 155        # disc number & track number must be non-negative integers or empty
 156        # (empty becomes one)
 157
 158        if discNumber == "":
 159            discNumber = 1
 160        else:
 161            try:
 162                discNumber = int(discNumber)
 163                if discNumber < 0:
 164                    raise ValueError
 165            except ValueError:
 166                print_error("invalid disc number", lineNum + 2)
 167
 168        if trackNumber == "":
 169            trackNumber = 1
 170        else:
 171            try:
 172                trackNumber = int(trackNumber)
 173                if trackNumber < 0:
 174                    raise ValueError
 175            except ValueError:
 176                print_error("invalid track number", lineNum + 2)
 177
 178        # add important info to list
 179        songs.append({
 180            "name":        name,
 181            "artist":      artist,
 182            "album":       album,
 183            "time":        time_,
 184            "discNumber":  discNumber,
 185            "trackNumber": trackNumber,
 186            "comments":    comments,
 187        })
 188
 189    return songs
 190
 191def format_total_song_length(seconds):
 192    """E.g. 3599 -> 0 d 0 h 59 min 59 s"""
 193
 194    seconds = int(seconds)
 195
 196    return "{days:d} d {hours:d} h {minutes:d} min {seconds:d} s".format(
 197        days = seconds // 86400,
 198        hours = seconds // 3600 % 24,
 199        minutes = seconds // 60 % 60,
 200        seconds = seconds % 60,
 201    )
 202
 203def write_summary(songs, hnd):
 204    """Write summary to HTML file."""
 205
 206    # get stats
 207    stats = {
 208        "songs": len(songs),
 209        "artists": len(set(song["artist"] for song in songs)),
 210        "albums": len(set(song["album"] for song in songs)),
 211        "time": sum(song["time"] for song in songs),
 212    }
 213
 214    generationTime = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
 215    totalSongLength = format_total_song_length(stats["time"])
 216
 217    print('<ul id="summary">', file = hnd)
 218    print("<li>generated at: {:s} UTC".format(generationTime), file = hnd)
 219    print("<li>artists: {:d}".format(stats["artists"]), file = hnd)
 220    print("<li>albums: {:d}".format(stats["albums"]), file = hnd)
 221    print("<li>songs: {:d}".format(stats["songs"]), file = hnd)
 222    print("<li>total song length: {:s}".format(totalSongLength), file = hnd)
 223    print("</ul>", file = hnd)
 224
 225def move_initial_the(name):
 226    """E.g. The Pentti Puntti Band -> Pentti Puntti Band, The"""
 227
 228    if name.lower().startswith("the "):
 229        return name[3:].strip() + ", " + name[:3]
 230
 231    return name
 232
 233def format_song_length(seconds):
 234    """E.g. 3599 -> 59:59"""
 235
 236    seconds = int(seconds)
 237
 238    return "{minutes:d}:{seconds:02d}".format(
 239        minutes = seconds // 60,
 240        seconds = seconds % 60,
 241    )
 242
 243def format_song(info):
 244    """Return formatted song info."""
 245
 246    # format comment
 247    if info["comments"] != "":
 248        comments = " <i>(" + info["comments"] + ")</i>"
 249    else:
 250        comments = ""
 251
 252    return (
 253        "<tt>{track:2d}.</tt> {name:s}{comments:s} <b>({time:s})</b>"
 254    ).format(
 255        track = info["trackNumber"],
 256        name = info["name"].translate(HTML_SPECIAL_CHARS),
 257        comments = comments,
 258        time = format_song_length(info["time"]),
 259    )
 260
 261def write_songs(songs, hnd):
 262    """Write songs to HTML file."""
 263
 264    # sort songs
 265    songs.sort(key = lambda song: song["trackNumber"])
 266    songs.sort(key = lambda song: song["discNumber"])
 267    songs.sort(key = lambda song: move_initial_the(song["album"]))
 268    songs.sort(key = lambda song: move_initial_the(song["album"]).lower())
 269    songs.sort(key = lambda song: move_initial_the(song["artist"]))
 270    songs.sort(key = lambda song: move_initial_the(song["artist"]).lower())
 271
 272    # used to detect change of artist/album/disc
 273    prevArtist = ""
 274    prevAlbum = ""
 275    prevDiscNumber = 0
 276
 277    # start playlist
 278    print('<ul id="songs">', file = hnd)
 279
 280    # print artists, albums & songs
 281    for song in songs:
 282        artist = song["artist"]
 283        album = song["album"]
 284        discNumber = song["discNumber"]
 285
 286        # four possibilities:
 287        # - artist, album & disc change
 288        # - album & disc change
 289        # - disc changes
 290        # - nothing changes
 291
 292        if artist != prevArtist or album != prevAlbum or \
 293        discNumber != prevDiscNumber:
 294            # end disc
 295            if prevDiscNumber != 0:
 296                print("</ul>", file = hnd)
 297
 298            if artist != prevArtist or album != prevAlbum:
 299                # end album
 300                if prevAlbum != "":
 301                    print("</ul>", file = hnd)
 302
 303                if artist != prevArtist:
 304                    # end artist
 305                    if prevArtist != "":
 306                        print("</ul>", file = hnd)
 307                    # start artist
 308                    print(
 309                        "<li>" + artist.translate(HTML_SPECIAL_CHARS),
 310                        file = hnd
 311                    )
 312                    print("<ul>", file = hnd)
 313                    prevArtist = artist
 314
 315                # start album
 316                print("<li>" + album.translate(HTML_SPECIAL_CHARS), file = hnd)
 317                print("<ul>", file = hnd)
 318                prevAlbum = album
 319
 320            # start disc
 321            print('<li>disc {:s}'.format(
 322                SMALL_NUMBERS.get(discNumber, str(discNumber))
 323            ), file = hnd)
 324            print("<ul>", file = hnd)
 325            prevDiscNumber = discNumber
 326
 327        # song
 328        print("<li>" + format_song(song), file = hnd)
 329
 330    # end last disc, last album, last artist & playlist
 331    print("</ul>", file = hnd)
 332    print("</ul>", file = hnd)
 333    print("</ul>", file = hnd)
 334    print("</ul>", file = hnd)
 335
 336def main():
 337    # exit if wrong number of args
 338    if len(sys.argv) != 3:
 339        exit(HELP_TEXT)
 340
 341    # read args
 342    (source, target) = sys.argv[1:]
 343
 344    # source and target files must not be the same
 345    try:
 346        if os.path.samefile(source, target):
 347            exit("Error: source and target files must not be the same.")
 348    except OSError:
 349        pass
 350
 351    # read source file
 352    try:
 353        with open(source, "rt", encoding = SOURCE_ENCODING) as hnd:
 354            songs = read_songs(hnd)
 355    except OSError:
 356        exit("Error reading source file!")
 357    except UnicodeError:
 358        exit("Error: source file not valid in specified character encoding!")
 359
 360    if len(songs) == 0:
 361        exit("Error: no songs!")
 362    print("Read {:d} song(s).".format(len(songs)))
 363
 364    # write target file
 365    try:
 366        with open(target, "wt", encoding = TARGET_ENCODING, newline = "\n") \
 367        as hnd:
 368            hnd.seek(0)
 369            print(HTML_START, file = hnd)
 370            print("<h2>Summary</h2>", file = hnd)
 371            write_summary(songs, hnd)
 372            print("", file = hnd)
 373            print("<h2>Songs</h2>", file = hnd)
 374            write_songs(songs, hnd)
 375            print(HTML_END, file = hnd)
 376    except OSError:
 377        exit("Error writing target file!")
 378
 379    print("OK.")
 380
 381if __name__ == "__main__":
 382    main()