/*
* titl - Tools for iTunes Libraries
* Copyright (C) 2008-2011 Joseph Walton
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kafsemo.titl;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import org.kafsemo.titl.diag.InputRange;
/**
* Development class to parse a library file. Fails as quickly as possibly
* on unknown structure. Saves the unobfuscated contents in a new file.
*/
public class ParseLibrary
{
private final Collection<Playlist> playlists = new ArrayList<Playlist>();
private Playlist currentPlaylist;
private Collection<Podcast> podcasts = new ArrayList<Podcast>();
private Collection<Track> tracks = new ArrayList<Track>();
private Track currentTrack;
private List<InputRange> diagnostics = new ArrayList<InputRange>();
private List<Artwork> resourcesWithArtwork = new ArrayList<Artwork>();
private Artwork currentArtwork;
public static void main(String[] args) throws Exception
{
if (args.length != 1) {
System.err.println("Usage: ParseLibrary <iTunes Library.itl>");
System.exit(5);
}
File f = new File(args[0]);
Library lib = parse(f);
OutputStream out = new FileOutputStream("decrypted-file");
out.write(lib.hdr.fileData);
out.close();
}
public static Library parse(File f) throws IOException, ItlException
{
long fileLength = f.length();
InputStream in = new FileInputStream(f);
try {
return parse(in, fileLength);
} finally {
in.close();
}
}
public static Library parse(InputStream in, long fileLength) throws IOException, ItlException
{
Input di = new InputImpl(in);
Hdfm hdr = Hdfm.read(di, fileLength);
ParseLibrary pl = new ParseLibrary();
String path = pl.drain(inputFor(hdr.fileData), hdr.fileData.length);
// for (InputRange ir : pl.diagnostics) {
// System.out.println(ir);
// }
Library library = new Library(hdr, path, pl.playlists, pl.podcasts, pl.tracks, pl.resourcesWithArtwork);
return library;
}
private static final byte[] flippedHdsm = {'m', 's', 'd', 'h'};
static Input inputFor(byte[] fileData)
{
InputStream in = new ByteArrayInputStream(fileData);
if (fileData.length >= 4 && Arrays.equals(flippedHdsm, Arrays.copyOfRange(fileData, 0, 4))) {
return new FlippedInputImpl(in);
} else {
return new InputImpl(in);
}
}
String drain(Input di, int totalLength) throws UnsupportedEncodingException, IOException, ItlException
{
int remaining = totalLength;
boolean going = true;
while(going)
{
InputRange thisChunk = new InputRange(di.getPosition());
diagnostics.add(thisChunk);
int consumed = 0;
String type = Util.toString(di.readInt());
consumed += 4;
int length = di.readInt();
consumed += 4;
// System.out.println(di.getPosition() + ": " + type + ": " + length);
thisChunk.length = length;
thisChunk.type = type;
int recLength;
if(type.equals("hohm"))
{
recLength = di.readInt();
consumed += 4;
// System.out.println("HOHM length: " + recLength);
int hohmType = di.readInt();
consumed += 4;
// System.out.printf("hohm type: 0x%02x - ", hohmType);
thisChunk.more = hohmType;
switch (hohmType)
{
case 1:
throw new IOException("Looks complicated...");
case 0x02: // Track title
String trackTitle = readGenericHohm(di);
if (currentTrack == null) {
throw new ItlException("Title with no track defined");
}
currentTrack.setName(trackTitle);
consumed = recLength;
break;
case 0x03: // Album title
String albumTitle = readGenericHohm(di);
if (currentTrack == null) {
throw new ItlException("Album title with no track defined");
}
currentTrack.setAlbum(albumTitle);
consumed = recLength;
break;
case 0x04: // Artist
String artist = readGenericHohm(di);
if (currentTrack == null) {
throw new ItlException("Artist with no track defined");
}
currentTrack.setArtist(artist);
consumed = recLength;
break;
case 0x05: // Genre
String genre = readGenericHohm(di);
if (currentTrack == null) {
throw new ItlException("genre with no track defined");
}
currentTrack.setGenre(genre);
consumed = recLength;
break;
case 0x06: // Kind
String kind = readGenericHohm(di);
if (currentTrack == null) {
throw new ItlException("kind with no track defined");
}
currentTrack.setKind(kind);
consumed = recLength;
// System.out.println("Kind: " + kind);
break;
case 0x0b: // Local path as URL XXX
String url = readGenericHohm(di);
if (currentTrack == null) {
throw new ItlException("Podcast URL with no track defined");
}
currentTrack.setLocalUrl(url);
consumed = recLength;
break;
case 0x0d: // Location
String location = readGenericHohm(di);
if (currentTrack == null) {
throw new ItlException("kind with no track defined");
}
currentTrack.setLocation(location);
consumed = recLength;
break;
case 0x13: // Download URL for podcast item
di.readInt(); // Index?
expectZeroBytes(di, 4);
consumed += 8;
byte[] ba = new byte[recLength - consumed];
di.readFully(ba);
String trackUrl = toString(ba);
if (currentTrack == null) {
throw new ItlException("URL with no track defined");
}
currentTrack.setUrl(trackUrl);
consumed = recLength;
break;
case 0x25: // Podcast URL for item
String pcUrl = readGenericHohm(di);
if (currentTrack == null) {
throw new ItlException("Podcast URL with no track defined");
}
currentTrack.setPodcastUrl(pcUrl);
// System.out.println("Podcast URL for item");
consumed = recLength;
break;
case 0x64: // (Smart?) Playlist title
String title = readGenericHohm(di);
// if (!title.equals("####!####")) {
if (currentPlaylist != null) {
if (currentPlaylist.title != null) {
throw new ItlException("Playlist title defined twice");
}
currentPlaylist.title = title;
} else {
throw new ItlException("Playlist title without defined playlist");
}
// }
// System.out.println("Playlist title: " + title);
consumed = recLength;
break;
case 0x131: // Podcast feed URL
String pcFeedUrl = readGenericHohm(di);
((Podcast) currentTrack).setPodcastLocation(pcFeedUrl);
consumed = recLength;
break;
case 0x190: // Podcast author (multiple)
String pcAuthor = readGenericHohm(di);
((Podcast) currentTrack).addPodcastAuthor(pcAuthor);
consumed = recLength;
break;
case 0x12C: // General title (XXX not just podcasts)
String pcTitle = readGenericHohm(di);
currentArtwork.setTitle(pcTitle);
// System.out.println(pcTitle);
((Podcast) currentTrack).setPodcastTitle(pcTitle);
consumed = recLength;
thisChunk.details = pcTitle;
break;
case 0x12D: // Another artist
String pcArtist = readGenericHohm(di);
currentArtwork.setArtist(pcArtist);
consumed = recLength;
thisChunk.details = pcArtist;
break;
case 0x12E: // Artist again? Album artist?
thisChunk.details = readGenericHohm(di);
consumed = recLength;
break;
case 0x132: // App title
String appTitle = readGenericHohm(di);
currentArtwork.setAppTitle(appTitle);
consumed = recLength;
thisChunk.details = appTitle;
break;
case 0x17: // iTunes podcast keywords
String keywords = readGenericHohm(di);
currentTrack.setItunesKeywords(keywords);
consumed = recLength;
break;
case 0x12: // Subtitle?
String subtitleOrFeedLink = readGenericHohm(di);
currentTrack.setItunesSubtitle(subtitleOrFeedLink);
// currentTrack.setFeedLink(subtitleOrFeedLink);
consumed = recLength;
break;
case 0x15: // (Only present in full DB)
hexDumpBytes(di, recLength - consumed);
// String v = readGenericHohm(di);
// System.out.println(v);
consumed = recLength;
break;
case 0x16: // iTunes summary?
String summary = readGenericHohm(di);
currentTrack.setItunesSummary(summary);
consumed = recLength;
break;
case 0x24: // (Only present in full DB)
hexDumpBytes(di, recLength - consumed);
// String v = readGenericHohm(di);
// System.out.println(v);
consumed = recLength;
break;
case 0x2B: // ISRC. Could also be a more generic recording ID?
String id = readGenericHohm(di);
consumed = recLength;
break;
case 0x30: // Data for apps
case 0x32: // DRM key files? for apps
expectZeroBytes(di, 8);
consumed += 8;
byte[] xmlBa = new byte[recLength - consumed];
di.readFully(xmlBa);
String plist = new String(xmlBa);
consumed = recLength;
break;
case 0x09: // iTunes category?
case 0x08:
case 0x14:
case 0x0c:
case 0x0e:
case 0x1b:
case 0x1e:
case 0x1f:
case 0x20:
case 0x21:
case 0x22:
case 0x2D: // A version string?
case 0x2E: // Copyright notice?
case 0x2F:
case 0x34:
case 0xc8: // Podcast episode list title
case 0xC9: // Podcast title
case 0x1F8: // A UUID. For?
case 0x1F9: // A UUID. For?
case 0x1FA: // An email address. For?
case 0x1FC: // The library name?
case 0x191: // Artist name without 'The'; sort artist
String val = readGenericHohm(di);
consumed = recLength;
thisChunk.more = hohmType + " [ignored] " + val;
break;
case 0x65: // Smart criteria
expectZeroBytes(di, 8);
byte[] smartCriteria = new byte[recLength - consumed - 8];
di.readFully(smartCriteria);
if (currentPlaylist.smartCriteria != null)
{
throw new ItlException("Unexpected duplicate smart criteria");
}
currentPlaylist.smartCriteria = smartCriteria;
consumed = recLength;
// System.out.println("Smart criteria");
break;
case 0x66: // Smart info
expectZeroBytes(di, 8);
byte[] smartInfo = new byte[recLength - consumed - 8];
di.readFully(smartInfo);
if (currentPlaylist.smartInfo != null)
{
throw new ItlException("Unexpected duplicate smart info");
}
currentPlaylist.smartInfo = smartInfo;
consumed = recLength;
// System.out.println("Smart info");
break;
case 0x67: // Podcast info?
byte[] pcInf = new byte[recLength - consumed];
// arrayDumpBytes(di, pcInf.length);
// System.exit(0);
di.readFully(pcInf);
// System.out.println(pcInf.length);
try {
currentPlaylist.setHohmPodcast(HohmPodcast.parse(
new InputImpl(new ByteArrayInputStream(pcInf)),
pcInf.length));
} catch (IOException ioe) {
// XXX Failed to parse podcast
}
consumed = recLength;
break;
/* Unknown, but seen */
case 0x68:
case 0x69:
case 0x6A: // A list of bands?
case 0x6b:
case 0x6c:
case 0x1f7:
case 0x1f4:
case 0x202: // ?? XML plist
case 0x320:
// int words = (recLength - consumed) / 4;
// hexDump(di, words);
// hexDumpBytes(di, (recLength - consumed) - words * 4);
di.skipBytes(recLength - consumed);
consumed = recLength;
thisChunk.more = hohmType + " [skipped]";
break;
// GLH: TV Show-related 'hohm's
//
// Description .XML Key?
case 0x18: // Show (on 'Video' tab) 'Series'
case 0x19: // Episode ID (on 'Video' tab) 'Episode'
case 0x1a: // ?? Studio/Producer, e.g. "Fox" --n/a--
case 0x1c: // mpaa Rating 'Content Rating'
case 0x1d: // ?? DTD for Propertylist --n/a--
case 0x23: // Sort-order for show title 'Sort Series'
case 0x130: // ?? Show/Series: I think it's used for
// building the 'TV Shows' menu, since
// there's one entry for each 'Season'
// within a given show.
String tvThing = readGenericHohm(di);
// System.out.println(String.format("0x%04x", hohmType) + ": " + tvThing);
consumed = recLength;
break;
default:
byte[] unknownHohmContents = new byte[recLength - consumed];
di.readFully(unknownHohmContents);
throw new UnknownHohmException(hohmType, unknownHohmContents);
}
}
else if(type.equals("hdsm"))
{
going = !readHdsm(di, length);
consumed = length;
}
else if (type.equals("hpim"))
{
readHpim(di, length);
consumed = length;
}
else if (type.equals("hptm"))
{
readHptm(di, length);
consumed = length;
}
else if (type.equals("htim"))
{
int extra = readHtim(di, length);
consumed = length;
consumed += extra;
}
else if (type.equals("haim"))
{
thisChunk.details = readHaim(di, length - consumed);
consumed = length;
}
else if (type.equals("hdfm")) {
Hdfm.readInline(di, length, consumed);
consumed = length;
}
else if (type.equals("hghm") || type.equals("halm") || type.equals("hilm") || type.equals("htlm") || type.equals("hplm")
|| type.equals("hiim") || type.equals("hslm") || type.equals("hpsm"))
{
di.skipBytes(length - consumed);
consumed = length;
}
else
{
// hexDumpBytes(di, length - consumed);
// consumed = length;
if (Util.isIdentifier(type)) {
throw new ItlException("Unhandled type: " + type);
} else {
throw new ItlException("Library format not understood; bad decryption (unhandled type: "
+ type + ")");
}
}
remaining -= consumed;
}
byte[] footerBytes = new byte[remaining];
di.readFully(footerBytes);
String footer = new String(footerBytes, "iso-8859-1");
// System.out.println("Footer: " + footer);
return footer;
}
static void hexDumpBytes(Input di, int count) throws IOException
{
for (int i = 0; i < count; i++) {
int v = di.readUnsignedByte();
// System.out.printf("%3d 0x%02x %4s\n", i, v, (v == 0 ? ' ' : (char) v));
}
}
// Byte Length Comment
// -----------------------
// 0' 12 ?
// 12 4 N = length of data
// 16 8 ?
// 24 N data
static String readGenericHohm(Input di) throws IOException, ItlException
{
byte[] unknown = new byte[12];
di.readFully(unknown);
int dataLength = di.readInt();
expectZeroBytes(di, 8, " in HOHM block");
byte[] data = new byte[dataLength];
di.readFully(data);
return toString(data, unknown[11]);
}
public static String toString(byte[] data) throws UnsupportedEncodingException
{
return new String(data, guessEncoding(data));
}
public static String toString(byte[] data, byte encodingFlag) throws ItlException, UnsupportedEncodingException
{
switch (encodingFlag) {
case 0: // Seems only to be used for URLs
return new String(data, "us-ascii");
case 1:
return new String(data, "utf-16be");
case 2:
return new String(data, "utf-8");
case 3:
return new String(data, "windows-1252");
default:
throw new ItlException("Unknown encoding type " + encodingFlag + " for string: " + new String(data));
}
}
public static String guessEncoding(byte[] data) throws UnsupportedEncodingException
{
if(data.length > 1 && data.length % 2 == 0 && data[0] == 0)
{
return "utf-16be";
}
else
{
return "iso-8859-1";
}
}
// Byte Length Comment
// -----------------------
// 0 4 'hdsm'
// 4 4 L = header length
// 8 4 ?
// 12 4 block type ?
// 16 L-16 ?
static boolean readHdsm(Input di, int length) throws IOException
{
// Assume header and length already read
int unknown = di.readInt();
int blockType = di.readInt();
di.skipBytes(length - 16);
// System.out.println("HDSM block type: " + blockType);
return (blockType == 4);
}
// Byte Length Comment
// -----------------------
// 0 4 hpim
// 4 4 N = length of data
// 8 4 ?
// 12 4 ?
// 16 4 number of items (hptm) in playlist
private void readHpim(Input di, int length) throws IOException, ItlException
{
int unknownA = di.readInt();
int unknownB = di.readInt();
int itemCount = di.readInt();
// System.out.println("HPIM items: " + itemCount);
// System.out.printf("0x%04x%04x\n", unknownA, unknownB);
byte[] remaining = new byte[length - 20];
di.readFully(remaining);
byte[] ppid = new byte[8];
System.arraycopy(remaining, 420, ppid, 0, ppid.length);
currentPlaylist = new Playlist();
currentPlaylist.ppid = ppid;
playlists.add(currentPlaylist);
}
private void readHptm(Input di, int length) throws IOException, ItlException
{
byte[] unknown = new byte[16];
di.readFully(unknown);
int key = di.readInt();
// System.out.println(" Key: " + key);
if (currentPlaylist == null) {
throw new ItlException("Playlist item outside playlist content");
}
currentPlaylist.addItem(key);
di.skipBytes(length - 28);
}
// Byte Length Comment
// -----------------------
// 0 4 'htim'
// 4 4 L = header length (usually 156, or 0x9C)
// 8 4 R = total record length, including sub-blocks
// 12 4 N = number of hohm sub-blocks
// 16 4 song identifier
// 20 4 block type => (1, ?)
// 24 4 ?
// 28 4 Mac OS file type (e.g. MPG3)
// 32 4 modification date
// 36 4 file size, in bytes
// 40 4 playtime, millisecs
// 44 4 track number
// 48 4 total number of tracks
// 52 2 ?
// 54 2 year
// 56 2 ?
// 58 2 bit rate
// 60 2 sample rate
// 62 2 ?
// 64 4 volume adjustment (signed)
// 68 4 start time, milliseconds
// 72 4 end time, milliseconds
// 76 4 playcount
// 80 2 ?
// 82 2 compilation (1 = yes, 0 = no)
// 84 12 ?
// 96 4 playcount again?
// 100 4 last play date
// 104 2 disk number
// 106 2 total disks
// 108 1 rating ( 0 to 100 )
// 109 11 ?
// 120 4 add date
// 124 32 ?
public int readHtim(Input di, int length) throws IOException
{
// 8 4 R = total record length, including sub-blocks
// 12 4 N = number of hohm sub-blocks
// 16 4 song identifier
// 20 4 block type => (1, ?)
int recordLength = di.readInt();
int subblocks = di.readInt();
int songId = di.readInt();
// System.out.println("Song ID: " + songId);
long blockType = di.readInt();
// System.out.println("Block type: " + blockType);
Track track = new Track();
track.setTrackId(songId);
// 24 4 ?
// 28 4 Mac OS file type (e.g. MPG3)
di.skipBytes(8);
// 32 4 modification date
int modificationDate = di.readInt();
track.setDateModified(Dates.fromMac(modificationDate));
// System.out.println("Modification date: " + Dates.fromMac(modificationDate));
// 36 4 file size, in bytes
int fileSize = di.readInt();
track.setSize(fileSize);
// System.out.println("File size: " + fileSize);
// 40 4 playtime, millisecs
int playtimeMillis = di.readInt();
track.setTotalTime(playtimeMillis);
// 44 4 track number
// 48 4 total number of tracks
// 52 2 ?
di.skipBytes(10);
// 54 2 year
int year = di.readShort();
track.setYear(year);
// 56 2 ?
di.skipBytes(2);
// 58 2 bit rate
track.setBitRate(di.readShort());
// 60 2 sample rate
track.setSampleRate(di.readShort());
// 62 2 ?
int x = di.readShort();
// 64 4 volume adjustment (signed)
// 68 4 start time, milliseconds
// 72 4 end time, milliseconds
di.skipBytes(12);
// 76 4 playcount
int playcount = di.readInt();
track.setPlayCount(playcount);
// 80 2 ?
// 82 2 compilation (1 = yes, 0 = no)
// 84 12 ?
di.skipBytes(16);
// 96 4 playcount again?
int playcountAgain = di.readInt();
if (playcount != playcountAgain && playcountAgain != 0 && playcountAgain != 1)
{
// throw new IOException(playcount + " != " + playcountAgain);
}
// System.out.println("Play count: " + playcount);
// 100 4 last play date
int lastPlayDate = di.readInt();
track.setLastPlayDate(Dates.fromMac(lastPlayDate));
// 104 2 disk number
// 106 2 total disks
di.skipBytes(4);
// 108 1 rating ( 0 to 100 )
int rating = di.readUnsignedByte();
track.setRating(rating);
// 109 11 ?
di.skipBytes(11);
// 120 4 add date
int addDate = di.readInt();
track.setDateAdded(Dates.fromMac(addDate));
// 124 32 ?
di.skipBytes(4);
byte[] persistentId = readPersistentId(di);
track.setPersistentId(persistentId);
di.skipBytes(20);
// System.out.println("Last play date: " + Dates.fromMac(lastPlayDate));
// System.out.println("Add date: " + Dates.fromMac(addDate));
long remaining = length - 156;
if (remaining > 152) {
di.skipBytes(144);
byte[] id = readPersistentId(di);
remaining -= 152;
track.setAlbumPersistentId(id);
}
di.skipBytes((int) remaining);
tracks.add(track);
currentTrack = track;
if (false)
{
// System.out.println("Skipping remaining: " + (recordLength - length));
di.skipBytes(recordLength - length);
return (recordLength - length);
}
else
{
return 0;
}
}
private static final byte[] BLANK_ID = new byte[8];
/* A Podcast header? */
String readHaim(Input di, int length) throws ItlException, IOException
{
if (length != 80) {
throw new IOException("Unexpected HAIM length. Expected 80, was " + length);
}
Podcast p = new Podcast();
podcasts.add(p);
currentTrack = p;
di.skipBytes(24);
byte[] persistentId = readPersistentId(di);
di.skipBytes(8);
expectZeroBytes(di, 40);
Artwork artwork;
if (persistentId != null) {
artwork = new Artwork(persistentId);
resourcesWithArtwork.add(artwork);
currentArtwork = artwork;
// Which track to associate this ID with?
// System.out.println("ID was: " + Util.pidToString(persistentId));
return Util.pidToString(persistentId);
} else {
artwork = new Artwork();
resourcesWithArtwork.add(artwork);
currentArtwork = artwork;
return null;
}
}
static void expectZeroBytes(Input di, int count) throws IOException, ItlException
{
expectZeroBytes(di, count, "");
}
static void expectZeroBytes(Input di, int count, String where) throws IOException, ItlException
{
byte[] ba = new byte[count];
di.readFully(ba);
for (int i = 0; i < ba.length; i++) {
byte b = ba[i];
if (b != 0x00) {
throw new ItlException("Expected " + count + " zero bytes" + where
+ ". Was: 0x" + Integer.toHexString(b) + " at offset " + i);
}
}
}
static byte[] readPersistentId(Input di) throws IOException
{
byte[] id = new byte[8];
di.readFully(id);
if (!Arrays.equals(id, BLANK_ID)) {
return id;
} else {
return null;
}
}
}