// Linko.pl - start of combined file 'Linko-all-3.0.9.js'
// Created: 2011-05-30 15:41:10

// Linko.pl - start of file 'core.js'
// core.js
// The purpose of this file is to ensure that the following are defined:
//
// console
// console.debug
// console.info
// console.log
// console.warn
// console.error
// console.fatal
// console.dir
// console.dirxml
// console.group
// console.groupEnd
// console.groupCollapsed
// console.time
// console.timeEnd
//
// Array.isArray
// Array.prototype.filter
// Array.prototype.forEach
// Array.prototype.indexOf
// Array.prototype.map
//
// Object.keys
//
// JSON
// JSON.parse
// JSON.stringify


(function () {
	if (typeof console === 'undefined') {
		try {
			console.log('Hello IE');
		}
		catch (e) {
			window.console = {};
		}
	}

	(function () {
		var times = {};
		var methods = [
			['log', null],
			['debug', 'log'],
			['info', 'log'],
			['warn', 'log'],
			['error', 'warn'],
			['fatal', 'error'],
			['dir', 'log'],
			['dirxml', 'log'],
			['group', function () {
				var argstr = Array.prototype.slice.call(arguments).join(' ');
				console.log(argstr + ': {');
			}],
			['groupCollapsed', 'group'],
			['groupEnd', function () {
				console.log('}');
			}],
			['time', function (id) {
				times[id] = (new Date()).getTime();
			}],
			['timeEnd', function (id) {
				if (!times[id]) {
					return;
				}
				console.log(id + ': ' + ((new Date()).getTime() - times[id]) + 'ms');
				delete times[id];
			}]
		];

		var set = function (method, fallback) {
			if (console[method]) {
				return true;
			}
			else if (typeof fallback === 'string' && console[fallback]) {
				console[method] = console[fallback];
			}
			else if (typeof fallback === 'function') {
				console[method] = fallback;
			}
			else if (fallback === null) {
				console[method] = function () {};
			}
			return true;
		};
		for (var i = 0; i < methods.length; ++i) {
			set(methods[i][0], methods[i][1]);
		}
	})();

	if (!Array.isArray) {
		Array.isArray = function (arg) {
			return Object.prototype.toString.call(arg) === '[object Array]';
		};
	}

	if (!Array.prototype.map) {
		Array.prototype.map = function (f) {
			var out = [];
			for (var i = 0; i < this.length; ++i) {
				out.push(f.call(arguments[1], this[i], i, this));
			}
			return out;
		};
	}

	if (!Array.prototype.filter) {
		Array.prototype.filter = function (f) {
			var out = [];
			for (var i = 0; i < this.length; ++i) {
				if (f.call(arguments[1], this[i], i, this)) {
					out.push(this[i]);
				}
			}
			return out;
		};
	}

	if (!Array.prototype.forEach) {
		Array.prototype.forEach = function (f) {
			for (var i = 0; i < this.length; ++i) {
				f.call(arguments[1], this[i], i, this);
			}
		};
	}

	if (!Array.prototype.indexOf) {
		Array.prototype.indexOf = function (elem) {
			var i = 0;
			if (arguments.length > 1) {
				i = arguments[1];
			}

			for (; i < this.length; ++i) {
				if (this[i] === elem) {
					return i;
				}
			}

			return -1;
		};
	}

	if (!Object.keys) {
		Object.keys = function (obj) {
			if (!obj) {
				return [];
			}

			var keys = [];
			if (Array.isArray(obj)) {
				obj.forEach(function (val, key) {
					keys.push(key);
				});
			}
			else {
				for (var key in obj) {
					// Direct method invocation breaks IE on some built-in objects
					if (Object.prototype.hasOwnProperty.call(obj, key)) {
						keys.push(key);
					}
				}
			}
			// IE...
			[
				'constructor',
				'toString',
				'valueOf',
				'toLocaleString',
				'isPrototypeOf',
				'propertyIsEnumerable',
				'hasOwnProperty'
			].forEach(function (key) {
				if (Object.prototype.hasOwnProperty.call(obj, key) && keys.indexOf(key) === -1) {
					keys.push(key);
				}
			});
			return keys;
		};
	}
})();

/* Pasta from http://www.json.org/json2.js */

if (!window.JSON) {
	window.JSON = {};
}

(function () {

    function f(n) {
        // Format integers to have at least two digits.
        return n < 10 ? '0' + n : n;
    }

    if (typeof Date.prototype.toJSON !== 'function') {

        Date.prototype.toJSON = function (key) {

            return isFinite(this.valueOf()) ?
                   this.getUTCFullYear()   + '-' +
                 f(this.getUTCMonth() + 1) + '-' +
                 f(this.getUTCDate())      + 'T' +
                 f(this.getUTCHours())     + ':' +
                 f(this.getUTCMinutes())   + ':' +
                 f(this.getUTCSeconds())   + 'Z' : null;
        };

        String.prototype.toJSON =
        Number.prototype.toJSON =
        Boolean.prototype.toJSON = function (key) {
            return this.valueOf();
        };
    }

    var cx = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,
        escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,
        gap,
        indent,
        meta = {    // table of character substitutions
            '\b': '\\b',
            '\t': '\\t',
            '\n': '\\n',
            '\f': '\\f',
            '\r': '\\r',
            '"' : '\\"',
            '\\': '\\\\'
        },
        rep;


    function quote(string) {

// If the string contains no control characters, no quote characters, and no
// backslash characters, then we can safely slap some quotes around it.
// Otherwise we must also replace the offending characters with safe escape
// sequences.

        escapable.lastIndex = 0;
        return escapable.test(string) ?
            '"' + string.replace(escapable, function (a) {
                var c = meta[a];
                return typeof c === 'string' ? c :
                    '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4);
            }) + '"' :
            '"' + string + '"';
    }


    function str(key, holder) {

// Produce a string from holder[key].

        var i,          // The loop counter.
            k,          // The member key.
            v,          // The member value.
            length,
            mind = gap,
            partial,
            value = holder[key];

// If the value has a toJSON method, call it to obtain a replacement value.

        if (value && typeof value === 'object' &&
                typeof value.toJSON === 'function') {
            value = value.toJSON(key);
        }

// If we were called with a replacer function, then call the replacer to
// obtain a replacement value.

        if (typeof rep === 'function') {
            value = rep.call(holder, key, value);
        }

// What happens next depends on the value's type.

        switch (typeof value) {
        case 'string':
            return quote(value);

        case 'number':

// JSON numbers must be finite. Encode non-finite numbers as null.

            return isFinite(value) ? String(value) : 'null';

        case 'boolean':
        case 'null':

// If the value is a boolean or null, convert it to a string. Note:
// typeof null does not produce 'null'. The case is included here in
// the remote chance that this gets fixed someday.

            return String(value);

// If the type is 'object', we might be dealing with an object or an array or
// null.

        case 'object':

// Due to a specification blunder in ECMAScript, typeof null is 'object',
// so watch out for that case.

            if (!value) {
                return 'null';
            }

// Make an array to hold the partial results of stringifying this object value.

            gap += indent;
            partial = [];

// Is the value an array?

            if (Object.prototype.toString.apply(value) === '[object Array]') {

// The value is an array. Stringify every element. Use null as a placeholder
// for non-JSON values.

                length = value.length;
                for (i = 0; i < length; i += 1) {
                    partial[i] = str(i, value) || 'null';
                }

// Join all of the elements together, separated with commas, and wrap them in
// brackets.

                v = partial.length === 0 ? '[]' :
                    gap ? '[\n' + gap +
                            partial.join(',\n' + gap) + '\n' +
                                mind + ']' :
                          '[' + partial.join(',') + ']';
                gap = mind;
                return v;
            }

// If the replacer is an array, use it to select the members to be stringified.

            if (rep && typeof rep === 'object') {
                length = rep.length;
                for (i = 0; i < length; i += 1) {
                    k = rep[i];
                    if (typeof k === 'string') {
                        v = str(k, value);
                        if (v) {
                            partial.push(quote(k) + (gap ? ': ' : ':') + v);
                        }
                    }
                }
            } else {

// Otherwise, iterate through all of the keys in the object.

                for (k in value) {
                    if (Object.hasOwnProperty.call(value, k)) {
                        v = str(k, value);
                        if (v) {
                            partial.push(quote(k) + (gap ? ': ' : ':') + v);
                        }
                    }
                }
            }

// Join all of the member texts together, separated with commas,
// and wrap them in braces.

            v = partial.length === 0 ? '{}' :
                gap ? '{\n' + gap + partial.join(',\n' + gap) + '\n' +
                        mind + '}' : '{' + partial.join(',') + '}';
            gap = mind;
            return v;
        }
    }

// If the JSON object does not yet have a stringify method, give it one.

    if (typeof JSON.stringify !== 'function') {
        JSON.stringify = function (value, replacer, space) {

// The stringify method takes a value and an optional replacer, and an optional
// space parameter, and returns a JSON text. The replacer can be a function
// that can replace values, or an array of strings that will select the keys.
// A default replacer method can be provided. Use of the space parameter can
// produce text that is more easily readable.

            var i;
            gap = '';
            indent = '';

// If the space parameter is a number, make an indent string containing that
// many spaces.

            if (typeof space === 'number') {
                for (i = 0; i < space; i += 1) {
                    indent += ' ';
                }

// If the space parameter is a string, it will be used as the indent string.

            } else if (typeof space === 'string') {
                indent = space;
            }

// If there is a replacer, it must be a function or an array.
// Otherwise, throw an error.

            rep = replacer;
            if (replacer && typeof replacer !== 'function' &&
                    (typeof replacer !== 'object' ||
                     typeof replacer.length !== 'number')) {
                throw new Error('JSON.stringify');
            }

// Make a fake root object containing our value under the key of ''.
// Return the result of stringifying the value.

            return str('', {'': value});
        };
    }


// If the JSON object does not yet have a parse method, give it one.

    if (typeof JSON.parse !== 'function') {
        JSON.parse = function (text, reviver) {

// The parse method takes a text and an optional reviver function, and returns
// a JavaScript value if the text is a valid JSON text.

            var j;

            function walk(holder, key) {

// The walk method is used to recursively walk the resulting structure so
// that modifications can be made.

                var k, v, value = holder[key];
                if (value && typeof value === 'object') {
                    for (k in value) {
                        if (Object.hasOwnProperty.call(value, k)) {
                            v = walk(value, k);
                            if (v !== undefined) {
                                value[k] = v;
                            } else {
                                delete value[k];
                            }
                        }
                    }
                }
                return reviver.call(holder, key, value);
            }


// Parsing happens in four stages. In the first stage, we replace certain
// Unicode characters with escape sequences. JavaScript handles many characters
// incorrectly, either silently deleting them, or treating them as line endings.

            text = String(text);
            cx.lastIndex = 0;
            if (cx.test(text)) {
                text = text.replace(cx, function (a) {
                    return '\\u' +
                        ('0000' + a.charCodeAt(0).toString(16)).slice(-4);
                });
            }

// In the second stage, we run the text against regular expressions that look
// for non-JSON patterns. We are especially concerned with '()' and 'new'
// because they can cause invocation, and '=' because it can cause mutation.
// But just to be safe, we want to reject all unexpected forms.

// We split the second stage into 4 regexp operations in order to work around
// crippling inefficiencies in IE's and Safari's regexp engines. First we
// replace the JSON backslash pairs with '@' (a non-JSON character). Second, we
// replace all simple value tokens with ']' characters. Third, we delete all
// open brackets that follow a colon or comma or that begin the text. Finally,
// we look to see that the remaining characters are only whitespace or ']' or
// ',' or ':' or '{' or '}'. If that is so, then the text is safe for eval.

            if (/^[\],:{}\s]*$/
.test(text.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g, '@')
.replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']')
.replace(/(?:^|:|,)(?:\s*\[)+/g, ''))) {

// In the third stage we use the eval function to compile the text into a
// JavaScript structure. The '{' operator is subject to a syntactic ambiguity
// in JavaScript: it can begin a block or an object literal. We wrap the text
// in parens to eliminate the ambiguity.

                j = eval('(' + text + ')');

// In the optional fourth stage, we recursively walk the new structure, passing
// each name/value pair to a reviver function for possible transformation.

                return typeof reviver === 'function' ?
                    walk({'': j}, '') : j;
            }

// If the text is not JSON parseable, then a SyntaxError is thrown.

            throw new SyntaxError('JSON.parse');
        };
    }
}());
// Linko.pl - end of file 'core.js'
// Linko.pl - start of file 'Linko.js'

var Linko = {
	'id': function (x) {
		return x;
	},
	'noop': function () {},
	'throw_': function (x) {
		throw x;
	},

	'initQueue': [],
	'svn': {
		'revision': '$Rev: 3757 $',
		'date':     '$Date: 2011-04-20 11:20:58 +0300 (ke, 20 huhti 2011) $'
	}
};

// Linko.pl - end of file 'Linko.js'
// Linko.pl - start of file 'conf.js'

Linko.conf = {
	'blankPage': 'blank.html?callback=Linko_log_popUpCallback',
	'util': {
		'ajax': {
			// Default to browser's native XHR for same-domain requests
			'native': true
		}
	},
	'player': {
		'initOrder': ['Flowplayer', 'WindowsMediaPlayer', 'VLC', 'QuickTime'],
		'blacklist': {},
		'whitelist': {}
	},
	'flowplayer': {
		'url': 'swf/flowplayer-3.2.4.swf',
		'plugins': {
			'audio':    'swf/flowplayer.audio-3.2.1.swf',
			'controls': '',
			'content':  ''
		}
	},
	'sprintf': {
		'decimalMark':        '.',
		'thousandsSeparator': ' '
	}
};

// Linko.pl - end of file 'conf.js'
// Linko.pl - start of file 'util/common.js'

Linko.initQueue.push(function () {
	var md5 = null;
	Linko.util.md5 = function (string) {
		if (!md5) {
			md5 = new Linko.MD5Hash();
		}
		if (!md5) {
			Linko.logger.error('Linko.util.md5: Couldn\'t create MD5Hash');
			return null;
		}
		md5.init();
		md5.update(string);
		return md5.getDigest();
	};
});

Linko.util = {
	'array': function (args) {
		try {
			return Array.prototype.slice.call(args);
		}
		catch (e) {
			var out = [];
			for (var i = 0; i < args.length; ++i) {
				out.push(args[i]);
			}
			return out;
		}
	},

	'argsToArray': function (args) {
		return Array.prototype.slice.call(args);
	},

	'try_': function (try_, catch_, finally_) {
		if (!catch_) {
			catch_ = Linko.noop;
		}
		if (!finally_) {
			finally_ = Linko.noop;
		}

		var t = setTimeout(function () {
			Linko.util.try_(
				catch_,
				Linko.noop,
				function () {
					finally_(false);
				}
			);
		}, 0);
		try_();
		clearTimeout(t);
		finally_(true);
	},

	'catch_': function (f, g) {
		try {
			return f();
		}
		catch (e) {
			return typeof g === 'function' ? g(e) : g;
		}
	},

	'method': function (obj, name) {
		return function (/* ... */) {
			return obj[name].apply(obj, Array.prototype.slice.call(arguments));
		};
	},

	'bind': function (f, self /* ... */) {
		var params = Array.prototype.slice.call(arguments, 2);
		return function () {
			return f.apply(self, params);
		};
	},

	'any': function (obj, f) {
		if (!f) {
			f = Linko.id;
		}
		return !Linko.util.each(obj, function (key, val) {
			return !f(val, key);
		});
	},

	'all': function (obj, f) {
		if (!f) {
			f = Linko.id;
		}
		return Linko.util.each(obj, function (key, val) {
			return !!f(val, key);
		});
	},

	'map': function (obj, f) {
		if (!f) {
			f = Linko.id;
		}
		var isArray = Array.isArray(obj);
		var out = isArray ? [] : {};
		Linko.util.each(obj, function (key, val) {
			out[key] = f(val, isArray ? +key : key);
		});
		return out;
	},

	'filter': function (obj, f) {
		if (!f) {
			f = Linko.id;
		}
		var isArray = Array.isArray(obj);
		var out = isArray ? [] : {};
		Linko.util.each(obj, function (key, val) {
			if (f(val, key)) {
				if (isArray) {
					out.push(val);
				}
				else {
					out[key] = val;
				}
			}
		});
		return out;
	},

	'baseType': function (x) {
		return !x || ['boolean', 'number', 'string', 'function'].indexOf(typeof x) !== -1;
	},

	'domObject': function (x) {
		// TODO: Find a better way
		return !!(x && x.nodeType);
	},

	'extend': function () {
		var args = Linko.util.array(arguments);

		var deep = true;
		if (typeof args[0] === 'boolean') {
			deep = args.shift();
		}

		args = args.filter(function (x) {
			return !Linko.util.baseType(x);
		});

		var out = args.shift() || {};

		args.forEach(function (obj) {
			Linko.util.each(obj, function (key, val) {
				if (val === undefined) {
					return;
				}
				if (!deep || Linko.util.baseType(val)) {
					out[key] = val;
					return;
				}
				try {
					val = Linko.util.copy(val);
				}
				catch (e) {
					Linko.logger.warn('util.extend: Linko.util.copy() failed');
				}
				out[key] = Linko.util.extend(deep, out[key], val);
			});
		});
		return out;
	},

	'flatten': function (xs) {
		if (!Array.isArray(xs)) {
			return [];
		}
		var flat = [];
		xs.forEach(function (x) {
			if (Array.isArray(x)) {
				flat = flat.concat(Linko.util.flatten(x));
			}
			else {
				flat.push(x);
			}
		});
		return flat;
	},

	'get': function (obj /* ,keys,keys,keys... */) {
		var path = Linko.util.flatten(Linko.util.array(arguments).slice(1));
		for (var i = 0; i < path.length; ++i) {
			obj = obj ? obj[path[i]] : undefined;
		}
		return obj;
	},

	'set': function (obj, val /* ,keys,keys,keys... */) {
		var path = Linko.util.flatten(Linko.util.array(arguments).slice(2));
		var at = obj || {};
		path.slice(0, path.length - 1).forEach(function (k) {
			if (!at[k]) {
				at[k] = {};
			}
			at = at[k];
		});
		at[path[path.length - 1]] = val;
	},

	'getDef': function (def, obj /* ,keys,keys,keys... */) {
		var x = Linko.util.get.apply(null, Linko.util.array(arguments).slice(1));
		return x === undefined ? def : x;
	},

	'callAll': function (fs, params, self) {
		if (typeof fs === 'function') {
			fs = [fs];
		}
		if (!Array.isArray(fs)) {
			return;
		}
		if (arguments.length < 2) {
			params = [];
		}
		if (!Array.isArray(params)) {
			params = [params];
		}
		fs.forEach(function (f) {
			f.apply(self || null, params);
		});
	},

	'isCircular': function (obj) {
		// DOM objects almost always have some circular references.
		// Trying to actually find one can be very expensive and seems to freeze IE9.
		if (Linko.util.domObject(obj)) {
			return true;
		}

		var sameStart = function (xs, ys) {
			var len = Math.min(xs.length, ys.length);
			return Linko.util.all(xs.slice(0, len), function (x, i) {
				return ys[i] === x;
			});
		};
		var visited = []; // visited[i] = [obj,path]
		var queue = [[obj, []]]; // queue[i] = [obj,path]
		var loopCounter = 0;
		while (queue.length > 0) {
			if (++loopCounter > 100) {
				// TODO: Sometimes this takes a looooong time.
				return true;
			}
			var x = queue.shift();
			if (Linko.util.baseType(x[0])) {
				continue;
			}
			var loop = Linko.util.any(visited, function (y) {
				return x[0] === y[0] && sameStart(x[1], y[1]);
			});
			if (loop) {
				return true;
			}
			visited.push(x);
			Linko.util.each(x[0], function (key, val) {
				queue.push([val, x[1].concat([key])]);
			});
		}
		return false;
	},

	'extendcb': function () {
		var args = Linko.util.array(arguments).filter(function (x) {
			return typeof x === 'function' || !Linko.util.baseType(x);
		});

		var out = args.shift() || {};

		var cbProperty = Linko.util.any([out].concat(args), function (x) {
			return x && x.callbacks;
		});
		if (cbProperty) {
			var cbs = Linko.util.extendcb.apply(null, [out.callbacks || {}].concat(args.map(function (x) { return x && x.callbacks; })));
			delete out.callbacks;
			Linko.util.extend.apply(null, [out].concat(args));
			out.callbacks = cbs;
			return out;
		}

		args.forEach(function (arg) {
			if (typeof arg === 'function') {
				arg = { 'success':arg };
			}
			Linko.util.each(arg, function (event, fs) {
				if (typeof out[event] === 'function') {
					out[event] = [out[event]];
				}
				else if (!out[event]) {
					out[event] = [];
				}
				if (typeof fs === 'function') {
					fs = [fs];
				}
				if (Array.isArray(out[event])) {
					out[event] = out[event].concat(fs);
				}
			});
		});

		return out;
	},

	'copy': function (x) {
		if (Linko.util.baseType(x)) {
			return x;
		}
//		try {
			return Linko.util.map(x, Linko.util.copy);
//		}
//		catch (e) {
//			return Linko.util.copy_(x);
//		}
	},

	'copy_': function (obj) {
		var out = undefined;

		var put = function (val, path) {
			if (path.length === 0) {
				out = val;
			}
			else {
				Linko.util.set(out, val, path);
			}
		};

		var visited = []; // list of [obj, path]
		var go = function (node, path) {
			if (Linko.util.baseType(node)) {
				put(node, path);
				return;
			}
			for (var i = 0; i < visited.length; ++i) {
				if (node === visited[i][0]) {
					put(Linko.util.get(out, visited[i][1]), path);
					return;
				}
			}

			put(Array.isArray(node) ? [] : {}, path);
			visited.push([node, path]);
			Linko.util.each(node, function (k, child) {
				go(node[k], path.concat([k]));
			});
		};

		go(obj, []);
		return out;
	},

	'eq': function (x, y) {
		// Optimization
		if (x === y) {
			return true;
		}

		if (Linko.util.baseType(x) || Linko.util.baseType(y)) {
			return x === y;
		}

		if (Array.isArray(x) !== Array.isArray(y)) {
			return false;
		}

		//x = Linko.util.removeCycles(x);
		//y = Linko.util.removeCycles(y);

		var xkeys = Object.keys(x).sort();
		var ykeys = Object.keys(y).sort();

		if (xkeys.length !== ykeys.length) {
			return false;
		}

		if (!Linko.util.all(xkeys, function (key, i) { return ykeys[i] === key; })) {
			return false;
		}

		return Linko.util.all(xkeys, function (key) {
			return Linko.util.eq(x[key], y[key]);
		});
	},

	'removeCycles': function (obj) {
		var out = [undefined];

		var put = function (val, path) {
			if (path.length === 0) {
				out = val;
			}
			else {
				var at = out;
				path.slice(0, path.length - 1).forEach(function (k) {
					at = at[0][k];
				});
				at[0][path[path.length - 1]] = val;
			}
		};

		var visited = []; // list of [obj, path]
		var go = function (node, path) {
			if (Linko.util.baseType(node)) {
				put([node], path);
				return;
			}
			for (var i = 0; i < visited.length; ++i) {
				if (node === visited[i][0]) {
					put([null, visited[i][1]], path);
					return;
				}
			}

			put([Array.isArray(node) ? [] : {}], path);
			visited.push([node, path]);
			Linko.util.each(node, function (k, child) {
				go(node[k], path.concat([k]));
			});
		};

		go(obj, []);
		return out;
	},

	'each': function (obj, f /*, self */) {
		var array = Array.isArray(obj);
		var out = true;
		var keys = [];
		try {
			keys = Object.keys(obj);
		}
		catch (e) {}
		keys.forEach(function (key) {
			if (!out) {
				return;
			}
			if (array && /^\d+$/.test(key)) {
				key = parseInt(key, 10);
			}
			if (f.call(arguments[2], key, obj[key], obj) === false) {
				out = false;
			}
		});
		return out;
	},

	'find': function (obj, f /*, def */) {
		if (!f) {
			f = function (val) {
				return val || undefined;
			};
		}

		var out = arguments[2];
		Linko.util.each(obj, function (key, val) {
			var x = f(val, key);
			if (x !== undefined) {
				out = x;
				return false;
			}
		});
		return out;
	},

	'once': function (f) {
		var called = false;
		return function () {
			if (called) {
				return;
			}
			called = true;
			return f();
		};
	},

	'uuid': (function () {
		var S4 = function () {
			return Math.floor((Math.random() + 1) * 0x10000).toString(16).substring(1);
		};
		return function () {
			return [S4() + S4(), S4(), S4(), S4(), S4() + S4() + S4()].join('-');
		};
	})(),

	// Deprecated, just use Object.keys from core.js
	'keys': Object.keys,

	'values': function (obj) {
		var values = [];
		Linko.util.each(obj, function (key, val) {
			values.push(val);
		});
		return values;
	},

	'isEmpty': function (obj) {
		return !Linko.util.any(obj, function () {
			return true;
		});
	},

	/**
	 * Convert an object to a query string, possibly prefixed with a '?'.
	 * @param {String|Object} params
	 * @param {Boolean}       [questionMark=false] If true and the formed query string is not empty, the return value starts with '?'
	 * @return {String}
	 */
	'makeQueryString': function (params, questionMark) {
		var queryStr = '';

		if (typeof params === 'string') {
			queryStr = params;
		}
		else {
			var components = [];
			Linko.util.each(params, function (k, v) {
				if (v !== undefined) {
					components.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
				}
			});
			// sorted to make the behaviour deterministic
			queryStr = components.sort().join('&');
		}

		var prefix = '';
		if (questionMark && queryStr.length > 0) {
			prefix = '?';
		}
		return prefix + queryStr;
	},

	/**
	 * Parse a query string.
	 * @param {String} [queryString=window.location.href]
	 * @return {Object}
	 */
	'parseQueryString': function (str) {
		if (arguments.length === 0) {
			var href = window && window.location && window.location.href || '';
			var m = href.match(/\?([^#]*)/);
			if (!m) {
				return {};
			}
			str = m[1];
		}

		var components = str === '' ? [] : str.split('&');
		var out = {};

		for (var i = 0; i < components.length; ++i) {
			var kv = components[i].match(/^([^=]*)=?([\s\S]*)$/m);
			try {
				var k = decodeURIComponent(kv[1]);
				var v = decodeURIComponent(kv[2] || '');
			}
			catch (e) {
				Linko.logger.error('parseQueryString: decodeURIComponent() threw an exception:', e);
				continue;
			}

			if (typeof out[k] === 'undefined') {
				out[k] = v;
			}
			else {
				if (!Array.isArray(out[k])) {
					out[k] = [out[k]];
				}
				out[k].push(v);
			}
		}

		return out;
	},

	/**
	 * Remove leading and trailing whitespace.
	 * @param {String} string
	 * @return {String}
	 */
	'trim': function (str) {
		return str.replace(/^\s+|\s+$/g, '');
	},

	/**
	 * Converts a number of milliseconds to a string. The time is rounded towards zero. If the parameter is null, undefined or NaN, '--:--' is returned.
	 * @param {Number} ms Time in milliseconds
	 * @return {String} '--:--' or m:ss or h:mm:ss
	 */
	'showTime': function (ms) {
		if (ms === undefined || ms === null || Array.isArray(ms)) {
			return '--:--';
		}

		ms = Number(ms);

		if (isNaN(ms) || !isFinite(ms)) {
			return '--:--';
		}

		var sign = ms < 0 ? '-' : '';
		ms = Math.abs(ms);

		var mins = Math.floor(ms / 1000 / 60);
		var secs = Math.floor(ms / 1000) % 60;

		if (secs < 10) {
			secs = '0' + secs;
		}

		var hours = '';
		if (mins >= 60) {
			hours = Math.floor(mins / 60) + ':';
			mins %= 60;
			if (mins < 10) {
				mins = '0' + mins;
			}
		}

		return sign + hours + mins + ':' + secs;
	},

	/**
	 * Convert a number of bytes to a string.
	 * @param {Number} bytes
	 * @param {Number} [decimalPlaces=1] How many digits to display after the decimal separator
	 * @param {Number} [step=1024]       Number of bytes in a KB
	 * @param {Number} [threshold]       When to use the bigger unit, default is 0.9
	 * @return {String} /^\d+(?:.\d+)? [KMGTPEZY]?B$/
	 */
	'showBytes': function (bytes, decimalPlaces, step, threshold) {
		if (!decimalPlaces && decimalPlaces !== 0) {
			decimalPlaces = 1;
		}

		if (!step) {
			step = 1024;
		}

		if (!threshold) {
			threshold = 0.9;
		}

		var symbols = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];

		for (var i = 0; i + 1 < symbols.length && bytes >= step * threshold; ++i) {
			bytes /= step;
		}

		return Number(bytes).toFixed(decimalPlaces) + ' ' + symbols[i] + 'B';
	},

	/**
	 * Calculate a digest from a string. The algorithm is MurmurHash2.
	 * @param {String} key
	 * @param {String} [seed=0]
	 * @return {Number}
	 */
	'hash':	function (key, seed) {
		if (!seed) {
			seed = 0;
		}

		var m = 0x5bd1e995;
		var r = 24;
		var len = key.length;

		var h = seed ^ len;

		var dwlen = len - len % 4;
		for (var i = 0; i < dwlen; i += 4) {
			var k = key.charCodeAt(i);
			k = (k << 8) + key.charCodeAt(i + 1) % 256;
			k = (k << 8) + key.charCodeAt(i + 2) % 256;
			k = (k << 8) + key.charCodeAt(i + 3) % 256;

			k *= m;
			k ^= k >> r;
			k *= m;

			h *= m;
			h ^= k;
		}

		switch (len % 4) {
		case 3: h ^= key.charCodeAt(len - 3) << 16;
		case 2: h ^= key.charCodeAt(len - 2) << 8;
		case 1: h ^= key.charCodeAt(len - 1);
			h *= m;
		}

		h ^= h >> 13;
		h *= m;
		h ^= h >> 15;

		return h;
	},

	/**
	 * Compare two version strings. Both should match /^\d+(\.\d+)*$/.
	 * @param {String} x
	 * @param {String} y
	 * @return {Number} Negative if x < y; positive if x > y; zero otherwise
	 */
	'compareVersions': function (x, y) {
		var regex = /^\d+(?:\.\d+)*$/;
		if (!regex.exec(x)) {
			x = '0';
		}
		if (!regex.exec(y)) {
			y = '0';
		}

		// drop trailing zeros
		x = x.replace(/\.[0.]+$/, '');
		y = y.replace(/\.[0.]+$/, '');

		var xs = x.split('.');
		var ys = y.split('.');

		var len = Math.min(xs.length, ys.length);
		for (var i = 0; i < len; ++i) {
			var nx = Number(xs[i]);
			var ny = Number(ys[i]);
			if (nx < ny) {
				return -1;
			}
			else if (nx > ny) {
				return 1;
			}
		}

		return xs.length - ys.length;
	},

	/**
	 * Plugin's XHR implementation is used if a dazzler object is available; otherwise, the browser's native XHR is used.
	 * To override this and always use the browser's implementation, pass the noPlugin parameter.
	 * @param {Boolean} [noPlugin=false] Use the browser's XHR implementation even if we have a dazzler object
	 * @return {XMLHttpRequest}
	 */
	'createXHR': function (noPlugin) {
		if (!noPlugin && Linko.system.getDazzler()) {
			try {
				var xhr = new Linko.XMLHttpRequest();
				if (xhr) {
					return xhr;
				}
			}
			catch (e) {}
		}

		if (window.XMLHttpRequest) {
			try {
				return new window.XMLHttpRequest();
			}
			catch (e) {}
		}

		if (window.ActiveXObject) {
			try {
				return new window.ActiveXObject('Microsoft.XMLHTTP');
			}
			catch (e) {}
		}

		return null;
	},

	'htmlEncode': function (string) {
		return $('<div/>').text(string).html().replace(/"/g, '&quot;');
	},

	'htmlDecode': function (string) {
		return $('<div/>').html(string).text();
	},

	'construct': function (A, args) {
		var B = function () {
			return A.apply(this, args);
		};
		B.prototype = A.prototype;
		return new B();
	},

	'hidePrivates': function (A) {
		return function () {
			var impl = Linko.util.construct(A, arguments);
			var self = this;
			Linko.util.each(A.prototype, function (name) {
				if (name.charAt(0) === '_' || typeof impl[name] !== 'function') {
					return;
				}
				self[name] = function () {
					return impl[name].apply(impl, arguments);
				};
			});
		};
	},

	/**
	 * Uses inline CSS to hide an element, while making sure that <object>s don't break;
	 * @param {DOMObject} object
	 */
	'hideElement': function (object) {
		object.setAttribute('style', 'width:1px;height:1px;left:-9000px;top:-9000px;position:absolute;');
	},

	/**
	 * Detect whether a plugin is available using navigator.plugins on web browsers and ActiveX on IE.
	 * @param {String|null} pluginName
	 * @param {String|null} activeXObject
	 * @return {Boolean}
	 */
	'detectPlugin': function (pluginName, activeXName) {
		if (pluginName && navigator.plugins && navigator.plugins.length) {
			for (var i = 0; i < navigator.plugins.length; ++i) {
				if (navigator.plugins[i].name.match(new RegExp(pluginName, 'i'))) {
					return true;
				}
			}
		}
		if (activeXName && window.ActiveXObject) {
			try {
				return !!new ActiveXObject(activeXName);
			}
			catch (e) {
				return false;
			}
		}

		return false;
	},

	/**
	 * Try to determine the media type of a file. If the filename and MIME type give conflicting types, filename is ignored.
	 * @param {Object} file
	 * @param {String} [file.mimeType]
	 * @param {String} [file.name]
	 * @return {String} /^(audio|video|image|unknown)$/
	 */
	'getMediaType': (function () {
		var regexes = {
			'extension': {
				'if_nomatch': 'unknown',
				'set': [
					{
						'rule'    : /\.(?:mp3|wma|wav|ogg|flac|au|aac|m4a)$/i,
						'if_match': 'audio'
					},
					{
						'rule'    : /\.(?:mp4|m4v|wmv|asf|mpg|mpeg|3gp|avi|mov|qt|divx|flv|xvid|vro|vob|swf|rm|ogm|mts|mkv|f4v|dvr-ms)$/i,
						'if_match': 'video'
					},
					{
						'rule'    : /\.(?:jpeg|jpg|gif|png|tif|bmp)$/i,
						'if_match': 'image'
					}
				]
			},
			'mime': {
				'if_nomatch': 'unknown',
				'set': [
					{
						'rule'    : /^audio/i,
						'if_match': 'audio'
					},
					{
						'rule'    : /^video/i,
						'if_match': 'video'
					},
					{
						'rule'    : /^image/i,
						'if_match': 'image'
					}
				]
			}
		};

		var matchGroup = function (group, against) {
			for (var i = 0; i < group.set.length; ++i) {
				if (group.set[i].rule.test(against)) {
					return group.set[i].if_match;
				}
			}

			return group.if_nomatch;
		};

		return function (file) {
			var type = matchGroup(regexes.mime, file.mimeType);

			if (type === 'unknown') {
				type = matchGroup(regexes.extension, file.name);
			}

			return type;
		};
	})()
};
// Linko.pl - end of file 'util/common.js'
// Linko.pl - start of file 'console.js'

/**
 * Linko.console: A thin wrapper around window.console to make
 * behaviour more consistent across browsers and IE.
 */
Linko.console = (function () {
	var stringify = function (x) {
		if (Linko.util.baseType(x)) {
			return String(x);
		}

		try {
			return JSON.stringify(x);
		}
		catch (e) {
			return '{...}';
		}
	};

	var handleParams = function (args) {
		var params = Linko.util.array(args);
		// IE joins the parameters with '' instead of ' '
		// IE and FF stringify objects to [object Object]
		return Linko.system.isIE || Linko.system.isFF ? [params.map(stringify).join(' ')] : params;
	};

	var console = {};

	['debug', 'info', 'log', 'warn', 'error', 'fatal'].forEach(function (method) {
		console[method] = function (/* ... */) {
			if (Linko.util.get(window, 'console', method)) {
				try {
					Function.prototype.apply.call(window.console[method], window.console, handleParams(arguments));
				}
				catch (e) {}
			}
		};
	});
	['dir', 'dirxml', 'group', 'groupCollapsed', 'groupEnd', 'time', 'timeEnd'].forEach(function (method) {
		console[method] = function (/* ... */) {
			if (window.console[method]) {
				try {
					Function.prototype.apply.call(window.console[method], window.console, arguments);
				}
				catch (e) {}
			}
		};
	});

	return console;
})();

// Linko.pl - end of file 'console.js'
// Linko.pl - start of file 'log/common.js'

Linko.log = {
	'loggers': {}
};

// Linko.pl - end of file 'log/common.js'
// Linko.pl - start of file 'log/Level.js'

Linko.log.Level = function (name, level) {
	this.name  = String(name);
	this.level = Number(level);
};

Linko.util.extend(Linko.log.Level.prototype, {
	'copy': function () {
		return new Linko.log.Level(this.name, this.level);
	},

	'toString': function () {
		return this.name;
	}
});

Linko.log.Level.ALL   = new Linko.log.Level('ALL',     0);
Linko.log.Level.TRACE = new Linko.log.Level('TRACE',  10);
Linko.log.Level.DEBUG = new Linko.log.Level('DEBUG',  20);
Linko.log.Level.INFO  = new Linko.log.Level('INFO',   30);
Linko.log.Level.WARN  = new Linko.log.Level('WARN',   40);
Linko.log.Level.ERROR = new Linko.log.Level('ERROR',  50);
Linko.log.Level.FATAL = new Linko.log.Level('FATAL',  60);
Linko.log.Level.OFF   = new Linko.log.Level('OFF',   100);

// Linko.pl - end of file 'log/Level.js'
// Linko.pl - start of file 'log/Message.js'

Linko.log.Message = function (opts) {
	opts = Linko.util.extend(false, {
		'data':   [],
		'level':  Linko.log.Level.INFO,
		'time':   new Date(),
		'source': Linko.log.getRootLogger()
	}, opts);

	this.data   = opts.data;
	this.level  = opts.level;
	this.time   = opts.time;
	this.source = opts.source;
};

Linko.util.extend(Linko.log.Message.prototype, {
	'copy': function () {
		return new Linko.log.Message({
			'data':   this.data,
			'level':  this.level.copy(),
			'time':   this.time,
			'source': this.source
		});
	},

	'toString': function () {
		var s = function (x) {
			if (Linko.util.baseType(x)) {
				return String(x);
			}

			try {
				return JSON.stringify(x);
			}
			catch (e) {
				return String('{<circular>}');
			}
		};
		var header = Linko.util.sprintf('%06d [%-5s] %s -', this.time.getTime() - Linko.log.initTime, this.level.toString(), this.source.name);
		return [header].concat(this.data.map(s)).join(' ');
	}
});

// Linko.pl - end of file 'log/Message.js'
// Linko.pl - start of file 'log/Appender.js'

Linko.log.Appender = function (opts) {
	opts = Linko.util.extend({
		'threshold': Linko.log.Level.INFO
	}, opts);
	this.threshold = opts.threshold;
};

Linko.log.Appender.prototype = {
	'doAppend': function (message) {
		if (this.threshold.level <= message.level.level) {
			this.append(message);
		}
	},

	// Specific appenders will override these
	'append':   Linko.noop,
	'group':    Linko.noop,
	'groupEnd': Linko.noop,
	'time':     Linko.noop,
	'timeEnd':  Linko.noop
};

// Linko.pl - end of file 'log/Appender.js'
// Linko.pl - start of file 'log/ArrayAppender.js'

Linko.log.ArrayAppender = function (opts) {
	opts = Linko.util.extend(false, {
		'threshold': Linko.log.Level.INFO
	}, opts);
	this.threshold = opts.threshold;
	this.messages  = [];
};

Linko.log.ArrayAppender.prototype = new Linko.log.Appender();

Linko.util.extend(Linko.log.ArrayAppender.prototype, {
	'append': function (message) {
		this.messages.push(['message', message]);
	},

	'group': function () {
		this.messages.push(['group', arguments]);
	},

	'groupEnd': function () {
		this.messages.push(['groupEnd', arguments]);
	}
});

// Linko.pl - end of file 'log/ArrayAppender.js'
// Linko.pl - start of file 'log/ConsoleAppender.js'

Linko.log.ConsoleAppender = function (opts) {
	opts = Linko.util.extend(false, {
		'threshold': Linko.log.Level.INFO
	}, opts);
	this.threshold = opts.threshold;
};

Linko.log.ConsoleAppender.prototype = new Linko.log.Appender();

Linko.util.extend(Linko.log.ConsoleAppender.prototype, {
	'append': function (message) {
		var method = {
			'ALL':   'debug',
			'TRACE': 'debug',
			'DEBUG': 'debug',
			'INFO':  'info',
			'WARN':  'warn',
			'ERROR': 'error',
			'FATAL': 'fatal',
			'OFF':   'fatal'
		}[message.level.name] || 'error';

		var header = Linko.util.sprintf('%6d [%-5s] %s -', message.time.getTime() - Linko.log.initTime, message.level.toString(), message.source.name);
		var params = [header].concat(message.data);
		Linko.console[method].apply(null, params);
	},

	'group': function (title, expanded) {
		if (expanded) {
			Linko.console.group(title);
		}
		else {
			Linko.console.groupCollapsed(title);
		}
	},

	'groupEnd': function () {
		Linko.console.groupEnd();
	}
});

// Linko.pl - end of file 'log/ConsoleAppender.js'
// Linko.pl - start of file 'log/Logger.js'

Linko.log.Logger = function (name) {
	this.name      = name;
	this.level     = null;
	this.appenders = [];
	this.additive  = true;
	Linko.log.loggers[this.name] = this;
};

Linko.log.getLogger = function (name) {
	return Linko.log.loggers[name] || new Linko.log.Logger(name);
};

Linko.log.Logger.prototype = {
	'getParents': function () {
		var parents = [];
		var self = this;
		Linko.util.each(Linko.log.loggers, function (name, logger) {
			if (self.name.substr(0, name.length + 1) === name + '.') {
				var i = 0;
				while (i < parents.length && parents[i].name.length < name) {
					++i;
				}
				parents.splice(i, 0, logger);
			}
		});
		parents.push(Linko.log.getRootLogger());

		return parents;
	},

	'getEffectiveLevel': function () {
		var chain = this.getParents();
		chain.unshift(this);
		return Linko.util.find(chain, function (logger) {
			return logger.level || undefined;
		});
	},

	'addAppender': function (app) {
		this.appenders.push(app);
	},

	'removeAppender': function (app) {
		this.appenders = this.appenders.filter(function (app2) {
			return app !== app2;
		});
	},

	'getAppenders': function () {
		var appenders = [];
		var loggers = [this].concat(this.getParents());
		Linko.util.each(loggers, function (i, logger) {
			logger.appenders.forEach(function (app) {
				if (appenders.indexOf(app) === -1) {
					appenders.push(app);
				}
			});
			return logger.additive;
		});
		return appenders;
	},

	'logMessage': function (message) {
		message = message.copy();
		message.source = this;
		var level = this.getEffectiveLevel();
		if (level.level <= message.level.level) {
			this.getAppenders().forEach(function (app) {
				app.doAppend(message);
			});
		}
	},

	'group': function (title, expanded) {
		this.getAppenders().forEach(function (app) {
			app.group(title, expanded);
		});
	},

	'groupEnd': function () {
		this.getAppenders().forEach(function (app) {
			app.groupEnd();
		});
	},

	'assert': function (value) {
		if (!value) {
			var params = ['Assertion failed'].concat(Linko.util.array(arguments).slice(1));
			if (params.length > 1) {
				params[0] += ':';
			}
			this.error.apply(this, params);
			throw 'Assertion failed';
		}
	},

	'log': function (level /* ... */) {
		this.logMessage(new Linko.log.Message({
			'level': level,
			'data':  Linko.util.array(arguments).slice(1)
		}));
	}
};

(function () {
	['trace', 'debug', 'info', 'warn', 'error', 'fatal'].forEach(function (method) {
		var level = Linko.log.Level[method.toUpperCase()];
		Linko.log.Logger.prototype[method] = function (/* ... */) {
			this.log.apply(this, [level].concat(Linko.util.array(arguments)));
		};
	});
})();

Linko.log.getRootLogger = (function () {
	var rootLogger = new Linko.log.Logger('[root]');
	rootLogger.level = Linko.log.Level.ALL;
	return function () {
		return rootLogger;
	};
})();

// Linko.pl - end of file 'log/Logger.js'
// Linko.pl - start of file 'log/PopUpAppender.js'

Linko.log.PopUpAppender = function (opts) {
	opts = Linko.util.extend(false, {
		'threshold': Linko.log.Level.INFO
	}, opts);
	this.threshold = opts.threshold;
	this.queue = [];
	this.prefix = '';
	this.indent = '    ';
	this.dom = {
		'window':    null,
		'document':  null,
		'container': null
	};
};

Linko.log.PopUpAppender.prototype = new Linko.log.Appender();

Linko.util.extend(Linko.log.PopUpAppender.prototype, {
	'init': function () {
		Linko.logger.debug('PopUpAppender: opening window with URL ' + Linko.conf.blankPage);
		var self = this;
		window['Linko_log_popUpCallback'] = function () {
			Linko.logger.debug('PopUpAppender: got callback from popup');
			self.dom.document = self.dom.window.document;
			var style = self.dom.document.createElement('style');
			style.type = 'text/css';
			var rules = self.dom.document.createTextNode(
				"body {\n" +
				"	margin: 0;\n" +
				"	padding: 8px;\n" +
				"}\n" +
				"#log {\n" +
				"	margin-top: 8px;\n" +
				//"	overflow: auto;\n" +
				//"	height: 748px;\n" +
				"}\n" +
				".message, .group {\n" +
				"	margin: 0;\n" +
				"	padding: 0;\n" +
				"	font-family: monospace;\n" +
				"	font-size: 13px;\n" +
				"	white-space: pre;\n" +
				"}\n" +
				".hidden {\n" +
				"	display: none;\n" +
				"}\n" +
				".message.trace {\n" +
				"	color: #AAA;\n" +
				"}\n" +
				".message.debug {\n" +
				"	color: #777;\n" +
				"}\n" +
				".message.info {\n" +
				"	color: #000;\n" +
				"}\n" +
				".message.warn {\n" +
				"	color: #A00;\n" +
				"}\n" +
				".message.error, .message.fatal {\n" +
				"	color: #F00;\n" +
				"}\n"
			);
			if (style.styleSheet) {
				style.styleSheet.cssText = rules.nodeValue;
			}
			else {
				style.appendChild(rules);
			}
			self.dom.document.getElementsByTagName('head')[0].appendChild(style);

			self.dom.document.body.innerHTML = (
				'<div id="container">' +
				'	<div id="header">' +
				'		<button type="button" id="button-trace">TRACE</button>' +
				'		<button type="button" id="button-debug">DEBUG</button>' +
				'		<button type="button" id="button-info">INFO</button>' +
				'		<button type="button" id="button-warn">WARN</button>' +
				'		<button type="button" id="button-error">ERROR</button>' +
				'		<button type="button" id="button-fatal">FATAL</button>' +
				'	</div>' +
				'	<div id="log"></div>' +
				'</div>'
			);
			self.levels.forEach(function (level) {
				var button = self.dom.document.getElementById('button-' + level);
				if (button.addEventListener) {
					button.addEventListener('click', function () { self.setLevel(level); }, false);
				}
				else {
					button.attachEvent('onclick', function () { self.setLevel(level); });
				}
			});
			self.dom.header = self.dom.document.getElementById('header');
			self.dom.log    = self.dom.document.getElementById('log');

			self.ready = true;

			while (self.queue.length > 0) {
				var event = self.queue.shift();
				self[event[0]].apply(self, event[1]);
			}
		};
		this.dom.window = window.open(Linko.conf.blankPage, 'log', 'width=1024,height=768,resizable=1,scrollbars=1');
		var wait = setInterval(function () {
			if (self.dom.window.closed) {
				self.ready = false;
				clearInterval(wait);
			}
		}, 1000);
	},

	'levels': ['trace', 'debug', 'info', 'warn', 'error', 'fatal'],

	'setLevel': function (level) {
		var hidden = [];
		for (var i = 0; i < this.levels.length; ++i) {
			if (this.levels[i] === level) {
				break;
			}
			else {
				hidden.push(this.levels[i]);
			}
		}
		Linko.util.array(this.dom.log.childNodes).forEach(function (node) {
			if (node.className.indexOf('message') === -1) {
				return;
			}
			var hide = false;
			hidden.forEach(function (level) {
				if (node.className.indexOf(level) !== -1) {
					hide = true;
				}
			});
			if (hide) {
				node.className += ' hidden';
			}
			else {
				node.className = node.className.replace(/ hidden/g, '');
			}
		});
	},

	'append': function (message) {
		if (!this.ready) {
			return void this.queue.push(['append', arguments]);
		}
		var div = this.dom.document.createElement('div');
		div.setAttribute('class', 'message ' + message.level.name.toLowerCase());
		div.innerHTML = Linko.util.htmlEncode(this.prefix + message.toString());
		this.dom.log.appendChild(div);
		//this.dom.container.innerHTML += Linko.util.sprintf('<div class="%s">%s</div>', 'message ' + message.level.name.toLowerCase(), this.prefix + message.toString());
	},

	'group': function (title, expanded) {
		if (!this.ready) {
			return void this.queue.push(['group', arguments]);
		}
		this.dom.log.innerHTML += '<div class="group">' + Linko.util.htmlEncode(this.prefix + title) + '</div>';
		this.prefix += this.indent;
	},

	'groupEnd': function () {
		if (!this.ready) {
			return void this.queue.push(['groupEnd', arguments]);
		}
		//this.dom.container.append(
		//	$('<div>', {
		//		'class': 'groupEnd',
		//		'text':  this.prefix + 'end'
		//	})
		//);
		this.prefix = this.prefix.substr(-this.indent.length);
	}
});

// Linko.pl - end of file 'log/PopUpAppender.js'
// Linko.pl - start of file 'log/init.js'

(function () {
	Linko.log.initTime = (new Date()).getTime();
	Linko.logger = Linko.log.getLogger('Linko');
	var get = Linko.util.parseQueryString();
	var level = Linko.log.Level[get['Linko-log-level']] || Linko.log.Level.INFO;

	var cApp = new Linko.log.ConsoleAppender({
		'threshold': level
	});
	Linko.logger.addAppender(cApp);

	if (get['Linko-log-popup'] !== undefined) {
		var pApp = new Linko.log.PopUpAppender({
			'threshold': level
		});

		Linko.log.getRootLogger().addAppender(pApp);
		$(document).click(function () {
			pApp.init();
			$(document).unbind('click');
		});
	}
})();

// Linko.pl - end of file 'log/init.js'
// Linko.pl - start of file 'EventSource.js'

Linko.EventSource = function (events) {
	this.events    = Linko.util.copy(events);
	this.listeners = {};
	// listeners[event] = Array of {id:Number, t:Number, f:Function}

	this.newId = (function () {
		var next = 1;
		return function () {
			return next++;
		};
	})();
};

(function () {
	var logger = Linko.EventSource.logger = Linko.log.getLogger('Linko.EventSource');

	Linko.EventSource.prototype = {
		'getEvents': function () {
			return Linko.util.copy(this.events);
		},

		'validEventName': function (event) {
			return event === '*' || this.events.indexOf(event) !== -1;
		},

		'getListeners': function (event) {
			return Linko.util.copy(this.listeners[event] || []).sort(function (a, b) {
				return a.t - b.t;
			});
		},

		'addCoreEventListener': function (when, event, listener) {
			logger.warn('addCoreEventListener is deprecated, use addEventListener instead');
			var t = when === 'before' ? -10 : 110;
			return this.addEventListener(t, event, listener);
		},

		'addEventListener': function (t, event, f) {
			if (arguments.length < 3) {
				return arguments.callee.call(this, 0, arguments[0], arguments[1]);
			}

			if (!this.validEventName(event)) {
				logger.error('addEventListener: Invalid event "' + event + '"');
				return null;
			}

			if (!this.listeners[event]) {
				this.listeners[event] = [];
			}

			var id = this.newId();
			this.listeners[event].push({ id:id, t:t, f:f });
			return id;
		},

		'removeEventListener': function (id) {
			return !!Linko.util.find(this.listeners, function (listeners, event) {
				return Linko.util.find(listeners, function (listener, i) {
					if (listener.id === id) {
						listeners.splice(i, 1);
						return true;
					}
				});
			});
		},

		'dispatchEvent': function (event, params) {
			if (!this.validEventName(event)) {
				logger.error('dispatchEvent: Invalid event: "' + event + '"');
				return false;
			}

			if (!params) {
				params = [];
			}

			var normalListeners = this.getListeners(event);
			var listeners = event === '*' ? normalListeners : normalListeners.concat(this.getListeners('*'));
			var async = false;

			var go = function (i) {
				if (i >= listeners.length) {
					return;
				}
				if (i === normalListeners.length) {
					params.unshift(event);
				}

				if (async) {
					try {
						listeners[i].f.apply(null, params);
					}
					catch (e) {
						logger.error('dispatchEvent: listener threw:', e);
					}
					go(i + 1);
					return;
				}
				Linko.util.try_(
					function () {
						listeners[i].f.apply(null, params);
					},
					function () {
						async = true;
						logger.error('dispatchEvent: listener threw');
					},
					function () {
						go(i + 1);
					}
				);
			};
			go(0);

			return true;
		}
	};
})();

// Linko.pl - end of file 'EventSource.js'
// Linko.pl - start of file 'util/ajax.js'

Linko.util.ajax = (function () {
	var activeRequests = {};
	var nextRequestId  = 1;

	var logger = Linko.log.getLogger('Linko.ajax');

	var ajaxES = new Linko.EventSource(['start', 'allDone', 'end']);

	ajaxES.addEventListener(-1, 'start', function (req) {
		activeRequests[req.requestId] = req;
	});

	ajaxES.addEventListener(-1, 'end', function (req) {
		if (!req || !activeRequests[req.requestId]) {
			return;
		}

		delete activeRequests[req.requestId];

		Linko.util.try_(
			function () {
				Linko.util.callAll(req.callbacks['finally'], [req]);
			},
			Linko.noop,
			function () {
				if (Linko.util.isEmpty(activeRequests)) {
					ajaxES.dispatchEvent('allDone', []);
				}
			}
		);
	});

	ajaxES.addEventListener(-1, 'end', function (req) {
		try {
			if (!req.done || req.failed) {
				return;
			}

			if (!(typeof req.cache === 'number' && req.cache > 0)) {
				return;
			}

			if (Linko.util.get(req, 'xhr', 'dontCache')) {
				return;
			}

			logger.debug(req.requestId + '> adding response to cache');
			ajax.addToCache(req.cache, req, {
				'status':  req.status,
				'headers': req.xhr.getAllResponseHeaders(),
				'body':    req.xhr.responseText || req.xhr.getResponseText()
			});
		}
		catch (e) {
			logger.debug(req.requestId + '> adding to cache failed with exception:', e);
		}
	});

	var AjaxRequest = function (args) {
		args = Linko.util.extend({}, Linko.util.ajax.defaults, args);

		this.callbacks = Linko.util.extendcb({}, args.callbacks);

		this.args      = Linko.util.copy(args);
		this.url       = args.url || '';
		this.params    = args.params || {};
		this.method    = args.method || 'GET';
		this.body      = args.body || null;
		this.async     = typeof args.async === 'undefined' ? !!args.callbacks.success : !!args.async;
		this.headers   = args.headers || {};
		this.jsonp     = !!args.jsonp;
		this.jsonpCallback
		               = args.jsonpCallback;
		this.parseJSON = args.parseJSON;
		this.cache     = args.cache === true ? Number.POSITIVE_INFINITY : args.cache || null;
		this.timeout   = args.timeout || null;
		this.createXHR = args.createXHR || Linko.util.createXHR;

		if (this.jsonp && !this.async) {
			logger.error('AjaxRequest: "jsonp" is true but "async" is false');
			throw 'jsonp && !async';
		}

		if (this.jsonp && this.method !== 'GET') {
			logger.error('AjaxRequest: "jsonp" is true but "method" === "' + this.method + '" !== "GET"');
			throw 'jsonp && method !== \'GET\'';
		}

		if (this.parseJSON) {
			if (!this.callbacks.filter) {
				this.callbacks.filter = [];
			}
			this.callbacks.filter.unshift(JSON.parse);
		}

		if (Linko.conf.util.ajax['native']) {
			var myOrigin = location.protocol + '//' + location.host + '/';
			if (!this.url.match(/^\w+:/) || this.url.slice(0, myOrigin.length) === myOrigin) {
				this.createXHR = function () {
					return Linko.util.createXHR(true);
				};
			}
		}

		this.requestId = nextRequestId++;
		this.events    = {};
		this.done      = false;
		this.xhr       = null;
		this.timedOut  = false;
		this.failed    = false;

		//logger.debug([this.requestId + '> AjaxRequest created -', this.async ? 'async' : 'sync', this.method, this.url].join(' '));

		var self = this;

		// JSONP adapter

		if (this.jsonp) {
			this.createXHR = function () {
				logger.debug(self.requestId + '> Using JSONP XHR adapter');
				var xhr = {};

				var changed = function () {
					if (xhr.onreadystatechange) {
						xhr.onreadystatechange(xhr.readyState, xhr.status);
					}
				};
				var gotResponse = function (ok, response) {
					if (ok) {
						xhr.body = response;
					}
					setTimeout(function () {
						xhr.readyState = 2;
						xhr.status = ok ? 200 : 400;
						changed(xhr);
						setTimeout(function () {
							xhr.readyState = 3;
							changed(xhr);
							setTimeout(function () {
								xhr.readyState = 4;
								changed(xhr);
							}, 0);
						}, 0);
					}, 0);
				};

				var callbackName = makeJSONPCallback(self, function (body) {
					gotResponse(true, body);
				});
				self.params[self.jsonpCallback] = callbackName;

				Linko.util.extend(xhr, {
					'setRequestHeader': Linko.noop,
					'getResponseText': function () {
						return this.body;
					},
					'getAllResponseHeaders': function () {
						return '';
					},
					'readyState': 0,
					'status': 0,
					'body': null,
					'abort': Linko.noop,
					'open': function () {
						//logger.debug(self.requestId + '> XHR JSONP: open()');
						this.readyState = 1;
						changed(this);
					},
					'send': function () {
						//logger.debug(self.requestId + '> XHR JSONP: send()');
						var script = document.createElement('script');
						script.setAttribute('id', self.params[self.jsonpCallback]);
						script.setAttribute('src', self.url);
						script.setAttribute('type', 'text/javascript');
						if (script.addEventListener) {
							script.addEventListener('error', function () {
								gotResponse(false, xhr);
							}, false);
						}
						else if (script.attachEvent) {
							script.attachEvent('onerror', function () {
								gotResponse(false, xhr);
							});
						}
						document.body.appendChild(script);
					}
				});

				return xhr;
			};
		}

		// Cache adapter

		if (this.cache > 0) {
			this.cacheKey = this.getCacheKey();
			var response = ajax.cache[this.cacheKey];
			if (!response) {
				return;
			}
			if (response.expires < (new Date).getTime()) {
				delete ajax.cache[this.cacheKey];
				return;
			}
			this.createXHR = function () {
				logger.debug(self.requestId + '> Using cache XHR adapter');
				var changed = function (xhr) {
					if (xhr.onreadystatechange) {
						xhr.onreadystatechange(xhr.readyState, xhr.status);
					}
				};
				return {
					'dontCache': true,
					'setRequestHeader': Linko.noop,
					'getResponseText': function () {
						if (this.readyState === 4) {
							return response.body;
						}
						else {
							return null;
						}
					},
					'getAllResponseHeaders': function () {
						if (this.readyState >= 2) {
							return response.headers;
						}
						else {
							return null;
						}
					},
					'readyState': 0,
					'status': 0,
					'abort': Linko.noop,
					'open': function () {
						//logger.debug(self.requestId + '> XHR Cache: open()');
						this.readyState = 1;
						changed(this);
					},
					'send': function () {
						//logger.debug(self.requestId + '> XHR Cache: send()');
						var xhr = this;
						if (self.async) {
							setTimeout(function () {
								xhr.readyState = 2;
								xhr.status = response.status;
								changed(xhr);
								setTimeout(function () {
									xhr.readyState = 3;
									changed(xhr);
									setTimeout(function () {
										xhr.readyState = 4;
										changed(xhr);
									}, 0);
								}, 0);
							}, 0);
						}
						else {
							xhr.readyState = 2;
							xhr.status = response.status;
							changed(xhr);
							xhr.readyState = 3;
							changed(xhr);
							xhr.readyState = 4;
							changed(xhr);
						}
					}
				};
			};
		}
	};

	AjaxRequest.prototype.getCacheKey = function () {
		var params = this.params;
		if (this.jsonp) {
			params = Linko.util.copy(params);
			delete params[this.jsonpCallback];
		}
		var self = this;
		var data = [
			this.method,
			this.args.url,
			params,
			this.args.body,
			this.args.headers
		];

		return Linko.util.hash(JSON.stringify(data));
	};

	AjaxRequest.prototype.abort = function () {
		if (this.done || this.failed) {
			return;
		}
		logger.debug(this.requestId + '> aborting request');
		try {
			this.xhr.abort();
		}
		finally {
			this.fail([null, 'aborted']);
		}
	};

	AjaxRequest.prototype.getRawResponse = function () {
		try {
			return this.xhr.responseText || this.xhr.getResponseText();
		}
		catch (e) {
			return '';
		}
	};

	AjaxRequest.prototype.getResponseNoThrow = function () {
		var data = null;

		try {
			data = this.getRawResponse();
			(this.callbacks.filter || []).forEach(function (f) {
				data = f(data);
			});
		}
		catch (e) {}

		return data;
	};

	// may throw
	AjaxRequest.prototype.getResponse = function () {
		var data = this.getRawResponse();

		(this.callbacks.filter || []).forEach(function (f) {
			data = f(data);
		});

		return data;
	};

	AjaxRequest.prototype.fail = function (params) {
		if (this.failed) {
			return;
		}
		logger.debug(this.requestId + '> Failed');
		this.failed = true;

		// We must ensure that the 'end' event is dispatched even if
		// some error callback throws. Using
		//
		//     try {
		//         Linko.util.callAll(this.callbacks.error, params);
		//     }
		//     finally {
		//         ajaxES.dispatchEvent('end', [this]);
		//     }
		//
		// makes the exception somehow disapper, at least on Chrome.
		// The best solution so far seems to be to dispatch the event
		// asynchronously in case a callback throws.

		var self = this;
		Linko.util.try_(
			function () {
				Linko.util.callAll(self.callbacks.error, params);
			},
			Linko.util.once(function () {
				logger.error(self.requestId + '> error callback threw');
			}),
			function () {
				ajaxES.dispatchEvent('end', [self]);
			}
		);
	};

	var ajax = function (options) {
		var req = new AjaxRequest(options);
		logger.debug([req.requestId + '> AjaxRequest created -', req.async ? 'async' : 'sync', req.method, req.url].join(' '));

		var returnObject = req;

		var callback = function () {
			if (req.done) {
				return;
			}
			var readyState = 0;
			try {
				if (req.xhr.getReadyState) {
					readyState = req.xhr.getReadyState();
				}
				else {
					readyState = req.xhr.readyState;
				}
			}
			catch (e) {}

			var status = 0;
			if (readyState >= 2) {
				try {
					if (req.xhr.getStatus) {
						status = req.xhr.getStatus();
					}
					else {
						status = req.xhr.status;
					}
				}
				catch (e) {}
			}

			try {
				if (readyState >= 2 && req.xhr && req.xhr.getAllResponseHeaders) {
					var headers = String(req.xhr.getAllResponseHeaders());
					var m = headers.match(/X-Linkotec-HTTP-Status:\s*(\d+)/i);
					if (m) {
						status = Number(m[1]);
					}
				}
			}
			catch (e) {}

			if (req.timedOut) {
				req.fail(['timed out']);
				return;
			}

			//if (typeof readyState === 'number' && typeof status === 'number' && readyState >= 1 && readyState <= 4 && status >= 100 && status < 600) {
			//	var key = status * 5 + readyState;
			//	if (req.events[key]) {
			//		return;
			//	}
			//	req.events[key] = true;
			//}

			// TODO
			//if (readyState === 0 && status === null) {
			//	logger.debug(req.requestId + '> invalid readyState', readyState);
			//	readyState = 4;
			//	status = 0;
			//}

			req.status = status;

			if (readyState !== 4) {
				switch (readyState) {
				case 1: case 2: case 3:
					Linko.util.callAll(req.callbacks.progress, [readyState, status, req]);
					break;
				default:
					logger.error(req.requestId + '> invalid readyState', readyState);
					break;
				}
				return;
			}

			req.done = true;

			if (status >= 200 && status < 300) {
				// ok
			}
			else if (status >= 300 && status < 400) {
				req.fail([req.getResponseNoThrow(), 'error, got HTTP redirect: "' + status + '"']);
				return;
			}
			else {
				req.fail([req.getResponseNoThrow(), 'error, HTTP status code: "' + status + '"']);
				return;
			}

			var response = null;
			try {
				response = req.getResponse();
			}
			catch (e) {
				req.fail([req.getResponseNoThrow(), e]);
				return;
			}

			Linko.util.try_(
				function () {
					Linko.util.callAll(req.callbacks.beforeSuccess, [response, status, req]);
					Linko.util.callAll(req.callbacks.success, [response, status, req]);
					returnObject = response;
				},
				function () {
					logger.error(req.requestId + '> beforeSuccess or success callback threw');
				},
				function () {
					ajaxES.dispatchEvent('end', [req]);
				}
			);
		};

		Linko.util.try_(
			function () {
				ajaxES.dispatchEvent('start', [req]);
			},
			function () {
				logger.error(req.requestId + '> "start" event listener threw');
				ajaxES.dispatchEvent('end', [req]);
			}
		);

		try {
			req.xhr = req.createXHR();

			if (!req.xhr) {
				throw 'creating XMLHttpRequest failed';
			}

			var useQueryString = req.method === 'GET' || req.body !== null;

			if (useQueryString) {
				req.url += Linko.util.makeQueryString(req.params, true);
			}

			req.xhr.onreadystatechange = callback;
			logger.debug(req.requestId + '> Calling xhr.open()');
			req.xhr.open(req.method, req.url, req.async);

			if (req.async && req.timeout !== null) {
				setTimeout(
					function () {
						if (!req.done) {
							req.abort();
							callback(4, 0);
						}
					},
					req.timeout
				);
			}

			var data = req.body;
			if (!useQueryString) {
				if (!req.headers['Content-Type']) {
					req.headers['Content-Type'] = 'application/x-www-form-urlencoded';
				}
				data = Linko.util.makeQueryString(req.params);
			}

			Linko.util.each(req.headers, function (k, v) {
				req.xhr.setRequestHeader(k, v);
			});

			logger.debug(req.requestId + '> Calling xhr.send()');
			req.xhr.send(data);
			if (!req.async) {
				Linko.util.try_(
					function () {
						if (req.xhr._onreadystatechange) {
							req.xhr._onreadystatechange();
						}
						else {
							callback();
						}
					},
					Linko.noop,
					function () {
						ajaxES.dispatchEvent('end', [req]);
					}
				);
			}
			return returnObject;
		}
		catch (e) {
			req.failed = true;
			var response = null;
			try {
				response = req.getResponse();
			}
			catch (e) {}

			req.fail([req.getResponseNoThrow(), e]);

			return returnObject;
		}
	};

	ajax.lookupCache = function (options) {
		var req = new AjaxRequest(options);
		var cacheKey = req.getCacheKey();
		var out = ajax.cache[cacheKey];
		//if (out) {
		//	logger.debug('found response from cache', req.method, req.url);
		//}
		return out;
	};

	ajax.addToCache = function (ttl, req, value) {
		if (ttl === true) {
			ttl = Number.POSITIVE_INFINITY;
		}

		if (!(typeof ttl === 'number' && ttl > 0)) {
			return null;
		}

		var expires = (new Date).getTime() + ttl;
		var cacheKey = Linko.util.get(req, 'cacheKey') || (req = new AjaxRequest(req)).getCacheKey();
		ajax.cache[cacheKey] = Linko.util.extend({}, value, {
			'expires': expires
		});

		//logger.debug(req.requestId + '> added response to cache');

		if (ttl < Number.POSITIVE_INFINITY) {
			setTimeout(function () {
				if (!ajax.cache[cacheKey]) {
					return;
				}
				if (ajax.cache[cacheKey].expires === expires) {
					delete ajax.cache[cacheKey];
				}
			}, ttl);
		}
		return cacheKey;
	};

	ajax.defaults = {
		'callbacks': {
			'success': function (response, status, req) {
				if (req.async) {
					logger.debug(req.requestId + '> success callback');
				}
			},

			'error': function (response, e) {
				logger.debug('error callback', response);
			},

			'finally':       [],
			'filter':        [],
			'beforeSuccess': [],
			'progress':      []
		}
	};

	var makeJSONPCallback = function (req, callback) {
		var funcName = '__Linko_' + req.requestId;
		while (window[funcName]) {
			funcName += '_' + Math.floor(Math.random() * 1000000);
		}
		window[funcName] = function (json) {
			try {
				delete window[funcName];
			}
			catch (e) {
				// IE
				window[funcName] = undefined;
			}
			$('#' + funcName).remove();
			callback(JSON.stringify(json));
		};
		return funcName;
	};

	ajax.cache = {};

	/**
	 * Add a listener for an Ajax event.
	 *
	 * Multiple listeners can be set for the same event.
	 * List of all events:
	 *
	 *  - start: request was sent
	 *  - end: request finished
	 *  - allDone: called after end if there are no active requests
	 *
	 * An end event is fired for every request, no matter how it ended.
	 *
	 * @param {String}   event  Name of the event
	 * @param {Function} cb     The callback
	 * @return {Number|null} An id for the listener on success, null on failure
	 */
	ajax.addEventListener = function (event, cb) {
		return ajaxES.addEventListener(event, cb);
	};

	ajax.removeEventListener = function (id) {
		return ajaxES.removeEventListener(id);
	};

	ajax.getActiveRequests = function () {
		return activeRequests;
	};

	ajax.logger = logger;

	return ajax;
})();
// Linko.pl - end of file 'util/ajax.js'
// Linko.pl - start of file 'util/cookie.js'

Linko.util.cookie = {
	/**
	 * Set a cookie.
	 * @param {String} name  Name of the cookie
	 * @param {String} value Value to be stored in the cookie
	 * @param {Number} [days] Number of days the cookie should live
	 */
	'set': function (name, value, days) {
		var expires = '';

		if (days) {
			var ms = days * 24 * 3600 * 1000;
			var date = new Date((new Date()).getTime() + ms);
			expires = '; expires=' + date.toGMTString();
		}

		document.cookie = name + '=' + value + expires + '; path=/';
	},

	/**
	 * Get a cookie.
	 * @param {String} name Name of the cookie
	 * @return {String} value of the cookie or null if it does not exist
	 */
	'get': function (name) {
		var pairs = document.cookie.split(';');
		return Linko.util.find(pairs, function (pair) {
			pair = Linko.util.trim(pair);
			if (pair.slice(0, name.length + 1) === name + '=') {
				return pair.slice(name.length + 1);
			}
		}, null);
	},

	'getAll': function () {
		var out = {};
		document.cookie.split(';').forEach(function (pair) {
			pair = Linko.util.trim(pair);
			var m = pair.match(/^([^=]*)=(.*)$/);
			if (!m) {
				Linko.logger.error('util.cookie.getAll: invalid pair:', pair);
				return;
			}
			out[m[1]] = m[2];
		});
		return out;

	},

	/**
	 * Remove a cookie.
	 * @param {String} name Name of the cookie
	 */
	'remove': function (name) {
		Linko.util.cookie.set(name, '', -1);
	}
};
// Linko.pl - end of file 'util/cookie.js'
// Linko.pl - start of file 'util/date.js'

Linko.util.date = (function () {
	var date = {};

	var formats = [
		'yyyy-m-d\\TH:M:sZ?',
		'yyyy-m-d H:M:sZ?',
		'yyyy-m-d\\TH:MZ?',
		'yyyy-m-d H:MZ?',
		'yyyy-m-d',
		'yyyy/m/d',
		'yyyy.m.d',
		'yyyy-d-m',
		'yyyy/d/m',
		'd.m.yyyy',
		'd-m-yyyy',
		'd/m/yyyy',
		'month d|th?, y',
		'yyyy|mm|dd'
	];

	date.parse = function (string) {
		for (var i = 0; i < formats.length; ++i) {
			var d = date.parseFormat(string, formats[i]);
			if (d !== null) {
				return d;
			}
		}

		return null;
	};

	date.parseFormat = function (string, format) {
		string = String(string);
		format = String(format);

		var year     = 0;
		var month    = 1;
		var day      = 1;
		var hour     = 0;
		var minute   = 0;
		var second   = 0;
		var timezone = 0;
		var pm       = null;

		while (format.length > 0) {
			var mf = null;
			var ms = null;

			// special characters

			if (mf = format.match(/^[|?]/)) {
				ms = [''];
			}

			// literal characters

			else if (mf = format.match(/^\\(.)/)) {
				if (string.charAt(0) === mf[1]) {
					ms = [mf[1]];
				}
			}

			// st,nd,rd,th

			else if (mf = format.match(/^th/)) {
				ms = string.match(/^(?:st|nd|rd|th)/);
			}

			// white space

			else if (mf = format.match(/^\s+/)) {
				ms = string.match(/^\s+/);
			}

			// year

			else if (mf = format.match(/^yyyy/)) {
				if (ms = string.match(/^(\d{4})/)) {
					year = Number(ms[1]);
				}
			}
			else if (mf = format.match(/^yy/)) {
				if (ms = string.match(/^(\d{2})/)) {
					var n = Number(ms[1]);
					var base = n <= 20 ? 2000 : 1900;
					year = base + n;
				}
			}
			else if (mf = format.match(/^y/)) {
				if (ms = string.match(/^(\d+)/)) {
					year = Number(ms[1]);
					if (year < 100) {
						year += year <= 20 ? 2000 : 1900;
					}
				}
			}

			// month

			else if (mf = format.match(/^month/)) {
				var monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'].map(function (name) {
					return new RegExp(Linko.util.sprintf('^%s(?:%s)?', name.slice(0, 3), name.slice(3)), 'i');
				});

				for (var i = 0; i < monthNames.length; ++i) {
					if (ms = string.match(monthNames[i])) {
						month = i + 1;
						break;
					}
				}
			}
			else if (mf = format.match(/^mm/)) {
				if (ms = string.match(/^(0\d|1[012])/)) {
					month = Number(ms[1]);
				}
			}
			else if (mf = format.match(/^m/)) {
				if (ms = string.match(/^(1[012]|0?[1-9])/)) {
					month = Number(ms[1]);
				}
			}

			// day

			else if (mf = format.match(/^dd/)) {
				if (ms = string.match(/^([012]\d|3[01])/)) {
					day = Number(ms[1]);
				}
			}
			else if (mf = format.match(/^d/)) {
				if (ms = string.match(/^([1-9](?!\d)|[012]\d|3[01])/)) {
					day = Number(ms[1]);
				}
			}

			// hour

			else if (mf = format.match(/^hh/)) {
				if (ms = string.match(/^(0\d|1[012])/)) {
					hour = Number(ms[1]);
				}
			}
			else if (mf = format.match(/^h/)) {
				if (ms = string.match(/^(\d(?!\d)|0\d|1[012])/)) {
					hour = Number(ms[1]);
				}
			}
			else if (mf = format.match(/^HH/)) {
				if (ms = string.match(/^([01]\d|2[0123])/)) {
					hour = Number(ms[1]);
				}
			}
			else if (mf = format.match(/^H/)) {
				if (ms = string.match(/^(\d(?!\d)|[01]\d|2[0123])/)) {
					hour = Number(ms[1]);
				}
			}

			// minute

			else if (mf = format.match(/^MM/)) {
				if (ms = string.match(/^([0-5]\d)/)) {
					minute = Number(ms[1]);
				}
			}
			else if (mf = format.match(/^M/)) {
				if (ms = string.match(/^(\d(?!\d)|[0-5]\d)/)) {
					minute = Number(ms[1]);
				}
			}

			// second

			else if (mf = format.match(/^ss/)) {
				if (ms = string.match(/^([0-5]\d)/)) {
					second = Number(ms[1]);
				}
			}
			else if (mf = format.match(/^s/)) {
				if (ms = string.match(/^(\d(?!\d)|[0-5]\d)/)) {
					second = Number(ms[1]);
				}
			}

			// am/pm

			else if (mf = format.match(/^a/)) {
				if (ms = string.match(/^([aApP]\.?[mM]\.?)/)) {
					pm = !!ms[1].match(/[pP]/);
				}
			}

			// time zone

			else if (mf = format.match(/^Z/)) {
				if (ms = string.match(/^Z/)) {
					timezone = 0;
				}
				else if (ms = string.match(/^\+(\d{4})/)) {
					var n = Number(ms[1]);
					timezone = Math.floor(n / 100) + (n % 100) / 60;
				}
				else if (ms = string.match(/^-(\d{4})/)) {
					var n = Number(ms[1]);
					timezone = -(Math.floor(n / 100) + (n % 100) / 60);
				}
			}

			if (mf) {
				if (ms) {
					format = format.slice(mf[0].length);
					string = string.slice(ms[0].length);
				}
				else if (format.charAt(mf[0].length) === '?') {
					format = format.slice(mf[0].length + 1);
				}
				else {
					return null;
				}
			}
			else {
				if (format.charAt(0).match(/[^|?\w]/)) {
					format = '\\' + format;
				}
				else {
					Linko.logger.error('Linko.util.date.parseFormat: invalid character in format string at "' + format + '"');
					return null;
				}
			}
		}

		hour -= timezone;

		if (pm) {
			hour += 12;
		}

		return new Date(Date.UTC(year, month - 1, day, hour, minute, second));
	};

	return date;
})();

// Linko.pl - end of file 'util/date.js'
// Linko.pl - start of file 'util/ProgressWidget.js'

Linko.util.ProgressWidget = (function () {
	var logger = Linko.log.getLogger('Linko.ProgressWidget');

	var ProgressWidget = function (options) {
		options = Linko.util.extend(false, {
			'container': $('<div/>').appendTo($('body'))
		}, options);

		this.dom = {
			'container': options.container,
			'total': {
				'title': $(),
				'progressbar': $()
			},
			'fileList': $()
		};

		var self = this;
		this.listenerId = Linko.system.addEventListener('onDownloadProgress', function (/* ... */) {
			return self.eventHandler.apply(self, arguments);
		});

		this.transfers = {};
		this.states = {
			'total':   {},
			'waiting': {},
			'active':  {},
			'failed':  {},
			'exists':  {},
			'done':    {}
		};
		this.lastCompleted = null;

		this.updateDOM();
		Linko.util.each(Linko.taskMonitor.getTransfers(), function (transferId, transfer) {
			self.setState(transfer, {
				'pending':  'waiting',
				'active':   'active',
				'canceled': 'failed',
				'done':     'done'
			}[transfer.state] || 'done');
		});
	};

	ProgressWidget.logger = logger;

	ProgressWidget.prototype.setState = function (transfer, state) {
		var id = Linko.util.getDef(transfer, transfer, 'id');
		if (!id) {
			logger.warn('setState: no transfer given');
			return false;
		}
		if (!this.states[state] || state === 'total') {
			logger.warn('setState: invalid state:', state);
			return false;
		}
		this.transfers[transfer.id] = transfer;

		var self = this;
		['waiting','active'].forEach(function (s) {
			delete self.states[s][id];
		});
		this.states.total[id] = 1;
		switch (state) {
			case 'waiting':
				this.states.waiting[id] = 1;
				break;
			case 'active':
				this.states.active[id]  = 1;
				break;
			case 'failed':
				this.states.failed[id]  = 1;
				this.states.done[id]    = 1;
				break;
			case 'exists':
				this.states.exists[id]  = 1;
				this.states.done[id]    = 1;
				break;
			case 'done':
				this.states.done[id]    = 1;
				break;
			default:
				logger.error('setState: invalid state:', state);
				break;
		}
		if (this.states.done[id]) {
			this.lastCompleted = this.transfers[id];
		}

		this.updateDOM();
		return true;
	};

	ProgressWidget.prototype.eventHandler = function (code, transfer /* ... */) {
		switch (code) {
		case 0: // Bunch begin
			break;

		case 11: // Request error
			this.setState(transfer, 'failed');
			break;

		case 12: // Download canceled
			this.setState(transfer, 'failed');
			break;

		case 13: // File already exists
			this.setState(transfer, 'exists');
			break;

		case 15: // Bunch failed
			break;

		case 49: // Transfer queued
			this.setState(transfer, 'waiting');
			break;

		case 50: // Transfer begin
			this.setState(transfer, 'active');
			break;

		case 51: // Transfer progress
			this.setState(transfer, 'active');
			break;

		case 52: // Transfer end
			this.setState(transfer, 'done');
			break;

		case 53: // Bunch end
			break;

		case 100: // Download queue end
			this.updateDOM();
			break;

		default:
			logger.error('eventHandler: unknown event code:', code);
			break;
		}
	};

	ProgressWidget.prototype.updateDOM = function () {
		var counts = {
			'total':   Object.keys(this.states.total).length,
			'waiting': Object.keys(this.states.waiting).length,
			'active':  Object.keys(this.states.active).length,
			'failed':  Object.keys(this.states.failed).length,
			'exists':  Object.keys(this.states.exists).length,
			'done':    Object.keys(this.states.done).length
		};

		var totalTitle = 'No transfers';
		var totalValue = 100;
		if (counts.total > 0) {
			totalTitle = Linko.util.sprintf('Files completed %d/%d', counts.done, counts.total);
			totalValue = 100 * counts.done / counts.total;
		}

		this.dom.container.empty().append(
			$('<div>', {
				'class': 'linko-progress-total'
			}).append(
				this.dom.total.title = $('<div/>', {
					'class': 'linko-progress-title',
					'text': totalTitle
				})
			).append(
				this.dom.total.progressbar = $('<div/>', {
					'class': 'linko-progress-progressbar' + (totalValue === 100 ? ' linko-progress-complete' : '')
				}).progressbar({
					'value': totalValue
				})
			)
		).append(
			this.dom.list = $('<ul/>', {
				'class': 'linko-progress-list'
			})
		);

		var self = this;
		var render = {};
		if (!Linko.util.isEmpty(this.states.active)) {
			render = this.states.active;
		}
		else if (counts.done < counts.total && this.lastCompleted) {
			render[this.lastCompleted.id] = 1;
		}
		Linko.util.each(render, function (transferId) {
			var transfer = self.transfers[transferId];
			if (!transfer) {
				logger.error('updateDOM: nonexistent transferId in states.active:', transferId);
				return;
			}
			var title = transfer.title;

			if (transfer.metadata.artist && transfer.metadata.track) {
				title = transfer.metadata.artist + ' - ' + transfer.metadata.track;
			}
			else if (transfer.metadata.track) {
				title = transfer.metadata.track;
			}

			var value = transfer.percent;
			var complete = value === 100;
			self.dom.list.append(
				$('<li/>', {
					'class': 'linko-progress-transfer'
				}).append(
					$('<div/>', {
						'class': 'linko-progress-title',
						'text': title
					})
				).append(
					$('<div/>', {
						'class': 'linko-progress-progressbar' + (complete ? ' linko-progress-complete' : '')
					}).progressbar({
						'value': value
					})
				)
			);
		});
	};

	ProgressWidget.prototype.kill = function () {
		if (this.listenerId) {
			Linko.system.removeEventListener(this.listenerId);
			this.listenerId = null;
		}
	};

	return ProgressWidget;
})();

// Linko.pl - end of file 'util/ProgressWidget.js'
// Linko.pl - start of file 'util/rename.js'

/**
 * A generic helper for transforming objects.
 *
 * The options parameter has recursive structure and describes the target object.
 * It can contain several options and a list of all properties for the target.
 *
 * Properties starting with '.' define a child.
 *
 * For convenience, if options is a String or an Array, it is replaced with { 'path':options }.
 *
 * Examples:
 *
 *   > Linko.util.rename({a:3}, 'a')
 *     3
 *
 *   > Linko.util.rename({a:{b:1}}, ['a','b'])
 *     1
 *
 *   > Linko.util.rename({a:4,b:{c:1}}, { '.x':'a', '.y':'b' })
 *     {x:4,y:{c:1}}
 *
 *   > Linko.util.rename({
 *       'id'        : '...',
 *       'first_name': 'Johannes',
 *       'last_name' : 'Laire',
 *       'full_name' : 'Johannes Laire'
 *     }, {
 *       '.name': {
 *         '.first': 'first_name',
 *         '.last' : 'last_name',
 *         '.full' : 'full_name'
 *       }
 *     })
 *     {
 *       'name': {
 *         'first': 'Johannes',
 *         'last' : 'Laire',
 *         'full' : 'Johannes Laire'
 *       }
 *     }
 *
 * Using filters:
 *
 *   > Linko.util.rename(-1, {
 *       'filter': function (n) {
 *         if (n < 0) throw '';
 *         return n;
 *       }
 *     })
 *     undefined
 *
 *   > Linko.util.rename(-1, {
 *       'filter': function (n) {
 *         if (n < 0) throw '';
 *         return n;
 *       },
 *       'required': true
 *     })
 *     (x) Linko.util.rename: filter failed for a required property ...
 *
 *   > Linko.util.rename(-1, {
 *       'filter': function (n) {
 *         if (n < 0) throw '';
 *         return n;
 *       },
 *       'def': 42
 *     })
 *     42
 *
 *   > Linko.util.rename({a:{b:5},x:{y:-1}}, {
 *       '.a': {
 *         'path': 'a',
 *         '.b'  : 'b'
 *       },
 *       '.x': {
 *         '.y': {
 *           'path'  : ['x','y'],
 *           'filter': function (y) {
 *             if (y < 0) throw '';
 *             return y;
 *           },
 *           'def': 0
 *         }
 *       }
 *     })
 *     {a:{b:5},x:{y:0}}
 *
 * @param {*}                      source                   The object to restructure
 * @param {Object|String|String[]} [options]                Describes the target object
 * @param {String|String[]}        [options.path]           Which property of the source to use
 * @param {function(*):*}          [options.filter]         The target object is passed through filter before returning
 * @param {function(*):Boolean}    [options.ok]             Called after filter to validate the target object
 * @param {Boolean}                [options.required=false]
 * @param {*}                      [options.constant]       If given, all other fields are ignored and options.constant is returned
 * @param {*}                      [options.def]            A default value, used if path isn't found in the source object
 * @return {*}
 * @throws {String} If required and either 1) path is not found in source and no def is given, or 2) filter throws, or 3) ok returns false
 */
Linko.util.rename = function (source, options) {
	if (typeof options === 'string' || typeof options === 'number' || Array.isArray(options)) {
		options = { 'path':options };
	}

	options = Linko.util.extend({}, options);

	if (typeof options.constant !== 'undefined') {
		return options.constant;
	}

	var isChild = function (key) {
		return String(key).charAt(0) === '.';
	};

	var targetProperties = {};
	Linko.util.each(options, function (key, value) {
		if (isChild(key)) {
			targetProperties[key] = value;
		}
	});

	// sanity check
	if (!Linko.util.isEmpty(targetProperties) && typeof options.def !== 'undefined') {
		Linko.logger.warn('Linko.util.rename: options.def is ignored when there are child properties');
	}

	var path = options.path || [];
	if (typeof path === 'string' || typeof path === 'number') {
		path = [path];
	}

	var exists = true;
	path.forEach(function (property) {
		if (typeof source[property] === 'undefined') {
			exists = false;
			return false;
		}

		source = source[property];
		return true;
	});

	if (options.required && !exists) {
		throw arguments;
	}

	var target = Linko.util.isEmpty(targetProperties) ? exists ? source : options.def : {};

	Linko.util.each(targetProperties, function (property, opts) {
		var child = Linko.util.rename(source, opts);
		if (child !== undefined) {
			target[property.slice(1)] = child;
		}
	});

	if (options.hasOwnProperty('filter')) {
		try {
			target = options.filter(target);
		}
		catch (e) {
			if (options.required) {
				throw arguments;
			}
			target = options.def;
		}
	}

	if (options.hasOwnProperty('ok')) {
		if (!options.ok(target)) {
			if (options.required) {
				throw 'not ok';
			}
			else {
				target = options.def;
			}
		}
	}

	return target;
};

Linko.util.makeRenamer = function (options) {
	return function (source) {
		return Linko.util.rename(source, options);
	};
};

Linko.util.makeRenamers = function (optionss) {
	var out = {};

	Linko.util.each(optionss, function (name, options) {
		out[name] = Linko.util.makeRenamer(options);
	});

	return out;
};

// Linko.pl - end of file 'util/rename.js'
// Linko.pl - start of file 'util/serialize.js'

(function () {
	var delim = ['\x02', '\x03', '\x04', '\x05', '\x06'];
	var isProperty = function (str) {
		return !!str.match(/^[a-zA-Z_]\w*$/);
	};

	/**
	 * Serialize an object.
	 * @param {Object} object
	 * @throws {String} if the object can't be serialized
	 * @return {String}
	 */
	Linko.util.serialize = function (object) {
		var depth = arguments[1] || 0;

		if (depth >= delim.length) {
			throw 'Maximum depth exceeded';
		}

		var s = '\x01';

		try {
			Linko.util.each(object, function (k, v) {
				switch (typeof v) {
				case 'string':
				case 'number':
					s += delim[depth] + k + delim[depth] + v;
					break;
				case 'object':
					s += delim[depth] + k + delim[depth] + Linko.util.serialize(v, depth + 1);
					break;
				default:
					Linko.logger.warn('Linko.util.serialize: Ignoring a property of unserializable type');
				}
			});
		}
		catch (e) {
			if (depth > 0) {
				throw e;
			}

			Linko.logger.error('Linko.util.serialize: Maximum depth exceeded');
			return null;
		}

		return s;
	};

	/**
	 * Unserialize an object.
	 * @param {String} string
	 * @throws {String}
	 * @return {Object|null}
	 */
	Linko.util.unserialize = function (string) {
		var depth = arguments[1] || 0;

		if (string === '\x01') {
			return {};
		}

		try {
			if (string.charAt(0) != '\x01') {
				throw 'Invalid string, should start with \\x01';
			}

			if (depth >= delim.length) {
				throw 'Maximum depth exceeded';
			}

			if (string.charAt(1) != delim[depth]) {
				throw 'Invalid string, delimeter character missing';
			}

			var arr = string.substring(2).split(delim[depth]);
			if (arr.length % 2 !== 0) {
				throw 'Property without value';
			}

			var out = {};
			for (var i = 0; i < arr.length; i += 2) {
				var k = arr[i];
				var v = arr[i + 1];

				if (isProperty(k)) {
					if (v.charAt(0) === '\x01') {
						v = Linko.util.unserialize(v, depth + 1);
					}
					out[k] = v;
				}
				else {
					Linko.logger.warn('Linko.util.unserialize: Ignoring invalid property');
				}
			}
			return out;
		}
		catch (e) {
			if (depth > 0) {
				throw e;
			}

			Linko.logger.error('Linko.util.unserialize: failed', string);
			return null;
		}
	};
})();

// Linko.pl - end of file 'util/serialize.js'
// Linko.pl - start of file 'util/sprintf.js'

/**
 * sprintf
 * @param {String} format
 * @param {*[]} stuffs
 * @return {String}
 */
Linko.util.sprintf = function (format, stuffs) {
	var out = '';

	var values = Linko.util.array(arguments).slice(1);

	var getParam = (function () {
		var nextIndex = 0;
		return function (index) {
			var m = typeof index === 'string' && index.match(/\d+/);
			if (m) {
				index = Number(m[0]);

				if (index === 0) {
					Linko.logger.error('sprintf: index can\'t be zero');
				}

				--index;
			}
			else {
				index = nextIndex++;
			}

			if (index >= values.length) {
				Linko.logger.error('sprintf: too few parameters');
			}

			return values[index];
		};
	})();

	var parseSpecifier = function (string) {
		//                     %[ argument ][  flags  ][         width         ][            precision            ][  length modifier  ][   content specifier   ]
		var m = string.match(/^%((?:\d+\$)?)([-'+#0 ]*)((?:-?\d+|\*(?:\d+\$)?)?)((?:\.(?:-\d+|\*(?:\d+\$)?|\d*)?)?)((?:[hljztL]|hh|ll)?)([diouxXfFeEgGaAcspnCS%])/);

		if (!m) {
			Linko.logger.error('Linko.util.sprintf: invalid specifier "' + string + '"');
			return null;
		}

		return {
			'spec'       : m[0],
			'param'      : m[1],
			'flags'      : m[2],
			'width'      : m[3],
			'precision'  : m[4],
			'length'     : m[5],
			'contentSpec': m[6]
		};
	};

	var unsigned = function (number) {
		// TODO
		return number >= 0 ? number : number & ~(1 << 31);
	};

	var setIntPrecision = function (number, precision) {
		number = String(number);

		if (precision === null) {
			return number;
		}

		if (precision === 0) {
			return number === '0' ? '' : number;
		}

		while (number.length < precision) {
			number = '0' + number;
		}

		return number;
	};

	var addThousandsSeparators = function (digits, flags) {
		if (!flags['\'']) {
			return digits;
		}

		var i = digits.indexOf('.');
		if (i === -1) {
			i = digits.length;
		}

		for (i -= 3; i > 0 && digits.substr(0, i).match(/[1-9]/); i -= 3) {
			digits = digits.substr(0, i) + Linko.conf.sprintf.thousandsSeparator + digits.substr(i);
		}

		return digits;
	};

	var addSign = function (val, flags) {
		var sign = flags['+'] ? '+' : flags[' '] ? ' ' : '';
		return val.match(/^[-+]/) ? val : sign + val;
	};

	var infnan = function (number, caps) {
		number = Number(number);
		if (isNaN(number)) {
			return caps ? 'NAN' : 'nan';
		}
		else if (number === Number.POSITIVE_INFINITY) {
			return caps ? 'INF' : 'inf';
		}
		else if (number === Number.NEGATIVE_INFINITY) {
			return caps ? '-INF' : '-inf';
		}
		else {
			return false;
		}
	};

	var toFixed = function (number, precision, flags) {
		number = Number(number);

		var sign = number < 0 ? '-' : '';
		number = Math.abs(number);

		var nat  = Math.floor(number);
		var frac = number - nat;

		var digits = [];
		if (nat === 0) {
			digits.unshift(0);
		}
		while (nat > 0) {
			digits.unshift(nat % 10);
			nat = Math.floor(nat / 10);
		}

		var intDigits = digits.length;

		for (var i = 0; i <= precision; ++i) {
			frac *= 10;
			var digit = Math.floor(frac);
			digits.push(digit);
			frac -= digit;
		}

		if (digits[digits.length - 1] >= 5) {
			var carry = 1;
			for (var i = digits.length - 2; i >= 0 && carry; --i) {
				digits[i] += carry;
				if (digits[i] > 9) {
					digits[i] -= 10;
				}
				else {
					carry = 0;
				}
			}
			if (carry) {
				digits.unshift(1);
				++intDigits;
			}
		}

		digits.pop();

		var str = sign + digits.slice(0, intDigits).join('');
		if (digits.length > intDigits || flags['#']) {
			str += Linko.conf.sprintf.decimalMark + digits.slice(intDigits).join('');
		}
		return str;
	};

	var toExponential = function (number, precision, flags, caps) {
		number = Number(number);
		var sign = number < 0 ? '-' : '';
		number = Math.abs(number);

		var exponent = 0;
		while (number < 1) {
			number *= 10;
			--exponent;
		}
		while (number >= 10) {
			number /= 10;
			++exponent;
		}

		var e = caps ? 'E' : 'e';
		return sign + toFixed(number, precision, flags) + e + Linko.util.sprintf('%02d', exponent);
	};

	while (format.length > 0) {
		var m = format.match(/^[^%]+/);
		if (m) {
			out += m[0];
			format = format.slice(m[0].length);
			continue;
		}

		Linko.logger.assert(format.charAt(0) === '%', 'sprintf: invariant failed', out, format);

		var spec = parseSpecifier(format);

		format = format.slice(spec.spec.length);

		/* flags */

		var flags = {};
		spec.flags.split('').forEach(function (flag) {
			flags[flag] = true;
		});

		if (flags[' '] && flags['+']) {
			Linko.logger.warn('sprintf: ignoring flag <space> because flag "+" is present');
			delete flags[' '];
		}

		/* field width */

		var width = null;
		if (spec.width !== '') {
			if (spec.width.match(/^\*(?:\d+\$)?$/)) {
				width = Math.floor(Number(getParam(spec.width)));
			}
			else if (spec.width.match(/^-?\d+$/)) {
				width = Number(spec.width);
			}
			else {
				Linko.logger.error('sprintf: invalid field width: "' + spec + '"');
				spec.width = 0;
			}

			if (width < 0) {
				flags['-'] = true;
				width = -width;
			}
		}

		/* precision */

		var precision = null;
		if (spec.precision !== '') {
			if (spec.precision === '.') {
				precision = 0;
			}
			else if (spec.precision.match(/^\.\*(?:\d+\$)?$/)) {
				precision = Math.floor(Number(getParam(spec.precision)));
			}
			else if (spec.precision.match(/^\.-?\d+$/)) {
				precision = Number(spec.precision.slice(1));
			}
			else {
				Linko.logger.error('sprintf: invalid precision: "' + spec + '"');
				precision = null;
			}

			if (precision < 0) {
				precision = null;
			}
		}

		/* length modifier */

		// ignored for now

		/* conversion specifier */

		var val = '';

		switch (spec.contentSpec) {
		case '%':
			val = '%';
			break;
		case 'c':
			val = getParam(spec.param);
			if (typeof val === 'number') {
				val = String.fromCharCode(val);
			}
			else {
				val = String(val);
				if (val.length !== 1) {
					Linko.logger.warn('sprintf: string given for %c has length !== 1 "' + val + '"');
				}
				val = val.length === 0 ? ' ' : val.charAt(0);
			}
			break;
		case 's':
			val = String(getParam(spec.param));
			if (precision !== null) {
				val = val.slice(0, precision);
			}
			break;
		case 'u':
			val = addThousandsSeparators(setIntPrecision(unsigned(Math.floor(getParam(spec.param))), precision), flags);
			break;
		case 'i':
		case 'd':
			val = addSign(addThousandsSeparators(setIntPrecision(Math.floor(getParam(spec.param)), precision), flags), flags);
			break;
		case 'o':
			val = addSign(addThousandsSeparators(setIntPrecision(Math.floor(getParam(spec.param)).toString(8), precision), flags), flags);

			if (flags['#']) {
				val = val.replace(/^([-+ ]?)([^0])/, '$10$2');
			}

			break;
		case 'x':
			val = setIntPrecision(Math.floor(getParam(spec.param)).toString(16).toLowerCase(), precision);
			if (flags['#']) {
				val = '0x' + val;
			}
			break;
		case 'X':
			val = setIntPrecision(Math.floor(getParam(spec.param)).toString(16).toUpperCase(), precision);
			if (flags['#']) {
				val = '0X' + val;
			}
			break;
		case 'f':
		case 'F':
			var param = getParam(spec.param);
			if (val = infnan(param, spec.contentSpec === 'F')) {
				break;
			}

			if (precision === null) {
				precision = 6;
			}

			val = addSign(addThousandsSeparators(toFixed(param, precision, flags), flags), flags);
			break;
		case 'e':
		case 'E':
			var caps = spec.contentSpec === 'E';
			var param = getParam(spec.param);
			if (val = infnan(param, caps)) {
				break;
			}

			if (precision === null) {
				precision = 6;
			}

			val = addSign(addThousandsSeparators(toExponential(param, precision, flags, caps), flags), flags);
			break;
		default:
			Linko.logger.error('sprintf: unsupported specifier "' + spec.contentSpec + '"');
		}

		/* handle field width */

		if (width !== null && val.length < width) {
			var padding = '';
			var padChar = flags['0'] && !flags['-'] ? '0' : ' ';
			while (val.length + padding.length < width) {
				padding += padChar;
			}

			if (flags['-']) {
				val += padding;
			}
			else {
				if (padChar === '0' && spec.contentSpec.match(/^[uidoxX]$/)) {
					var prefix = val.match(/^[-+ ]?(?:0[xX]?)?/)[0].length;
					val = val.substr(0, prefix) + padding + val.substr(prefix);
				}
				else {
					val = padding + val;
				}
			}
		}

		out += val;
	}

	return out;
};

// Linko.pl - end of file 'util/sprintf.js'
// Linko.pl - start of file 'CompoundDB.js'

Linko.initQueue.push(function () {
	Linko.compoundDB = new Linko.CompoundDB();
});

Linko.CompoundDB = (function () {
	var logger = Linko.log.getLogger('Linko.CompoundDB');

	var CompoundDB = function () {
		this.db = null;
	};

	CompoundDB.logger = logger;

	CompoundDB.prototype.init = function () {
		var computer = Linko.deviceManager.computer;
		if (!computer) {
			logger.error('init: no computer');
			return false;
		}

		this.db = computer.getDatabase('Compound');
		if (!this.db) {
			logger.error('init: computer.getDatabase() failed:', this.db);
			this.db = null;
			return false;
		}

		return true;
	};

	CompoundDB.prototype.refresh = function () {
		this.db = null;
		return this.init();
	};

	CompoundDB.prototype.execute = function (query) {
		if (!this.db) {
			this.init();
		}

		if (!this.db) {
			logger.error('execute: init failed');
			return null;
		}

		return this.db.execute(query);
	};

	CompoundDB.prototype.prepare = function (query) {
		if (!this.db) {
			this.init();
		}

		if (!this.db) {
			logger.error('prepare: init failed');
			return null;
		}

		return this.db.prepare(query);
	};

	CompoundDB.prototype.getDeviceIdByFileId = function (fileId) {
		if (!fileId) {
			logger.error('getDeviceIdByFileId: no fileId given');
			return null;
		}

		// Using CompoundDB for this is really slow

		return Linko.util.find(Linko.deviceManager.allDevices, function (device) {
			var db = device.getDatabase();
			if (!db) {
				return;
			}
			var rows = db.select({
				'cols':   ['1'],
				'tables': ['files'],
				'where':  ['files.id = ?'],
				'bind':   [fileId]
			});
			if (!rows) {
				return;
			}
			if (rows.length > 0) {
				return device.getId();
			}
		}) || null;

		/*
		if (!this.db) {
			this.init();
		}

		if (!this.db) {
			logger.error('getDeviceIdByFileId: no db');
			return null;
		}

		var rows = this.db.select({
			'cols':   ['deviceId'],
			'tables': ['files'],
			'where':  ['id = ?'],
			'bind':   [fileId]
		});
		if (!rows) {
			logger.error('getDeviceIdByFileId: db.select() failed');
			return null;
		}
		if (rows.length === 0) {
			logger.warn('getDeviceIdByFileId: file not found, fileId =', fileId);
			return null;
		}
		if (rows.length > 1) {
			logger.error('getDeviceIdByFileId: more than one device matched; fileId =', fileId);
		}

		return rows[0].deviceId;
		*/
	};

	CompoundDB.prototype.getFileURL = function (fileId) {
		var deviceId = this.getDeviceIdByFileId(fileId);
		if (!deviceId) {
			logger.error('getFileURL: fileId not found:', fileId);
			return null;
		}
		return Linko.system.getMediaServerURL() + 'DeviceManager/file/' + deviceId + '/' + fileId;
	};

	CompoundDB.prototype.getDeviceByFileId = function (fileId) {
		return Linko.deviceManager.getDeviceById(this.getDeviceIdByFileId(fileId));
	};

	return CompoundDB;
})();

// Linko.pl - end of file 'CompoundDB.js'
// Linko.pl - start of file 'Database.js'

Linko.Database = (function () {
	var logger = Linko.log.getLogger('Linko.Database');

	var Database = function (impl) {
		if (!impl) {
			logger.error('constructor: no impl');
			return null;
		}

		this.impl = impl;
		this._cache = {};
	};

	Database.logger = logger;

	Database.prototype.open = function (name) {
		try {
			return this.impl.Open(name);
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'open');
		}
		return false;
	};

	Database.prototype.close = function () {
		try {
			this.impl.Close();
			this.impl = null;
			this._cache = {};
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'close');
		}
		return false;
	};

	Database.prototype.prepare = function (statement) {
		try {
			return new Linko.Statement(this.impl.Prepare(statement));
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'prepare');
		}
		return null;
	};

	/**
	 * Delete the database.
	 */
	Database.prototype.remove = function () {
		try {
			this.impl.Close();
			this.impl.Remove();
			this.impl = null;
			this._cache = {};
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'remove');
		}
		return false;
	};

	Database.prototype.execute = function (statement) {
		var t1 = new Date();
		try {
			var rsImpl = this.impl.Execute(statement);
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'execute');
			return null;
		}
		var t2 = new Date();
		var t = t2.getTime() - t1.getTime();
		if (t > 50) {
			logger.trace('execute: ' + t + ' ms');
		}

		return new Linko.ResultSet(rsImpl);
	};

	Database.prototype.select = function (opts) {
		opts = Linko.util.extend(false, {
			'cols':    ['*'],
			'tables':  [],
			'where':   [],
			'group':   null,
			'order':   null,
			'limit':   null,
			'bind':    [],
			'getRows': true,
			'cache':   true
		}, opts);

		// Check opts

		if (opts.cols.length === 0) {
			logger.error('select: no cols');
			return null;
		}

		if (opts.tables.length === 0) {
			logger.warn('select: no tables');
		}

		var indent = function (x) { return '\n  ' + x; };

		// SELECT a, b, c

		var query = 'SELECT' + opts.cols.map(indent).join(',');

		// FROM x, y, z

		if (opts.tables.length > 0) {
			query += '\nFROM' + opts.tables.map(indent).join('');
		}

		// WHERE foo AND bar AND baz

		if (opts.where.length > 0) {
			query += '\nWHERE' + opts.where.map(function (cond) {
				return indent('(' + cond + ')');
			}).join(' AND');
		}

		// GROUP BY asdf

		if (opts.group) {
			query += '\nGROUP BY' + indent(opts.group);
		}

		// ORDER BY asdf

		if (opts.order) {
			query += '\nORDER BY' + indent(opts.order);
		}

		// LIMIT 1, 2

		if (opts.limit) {
			query += '\nLIMIT ' + opts.limit;
		}

		// ;

		query += ';';

		/***********/
		/* prepare */
		/***********/

		var q = null;
		var hash = Linko.util.hash(query);
		var fromCache = false;
		if (opts.cache && this._cache[hash]) {
			q = this._cache[hash];
			q.clear();
			fromCache = true;
		}
		else {
			q = this.prepare(query);
			if (!q) {
				logger.error('select: this.prepare() failed');
				return null;
			}
			if (opts.cache) {
				this._cache[hash] = q;
			}
		}

		if (fromCache) {
			logger.trace('select: query found in cache, hash =', hash);
		}
		else if (opts.cache) {
			logger.trace('select: hash =', hash, '- query =', '\n' + query);
		}
		else {
			logger.trace('select: query =\n' + query);
		}

		if (!q.bind(opts.bind)) {
			logger.error('select: q.bind() failed');
			return null;
		}

		var rs = q.execute();
		if (!rs) {
			logger.error('select: q.execute() failed');
			return null;
		}

		if (!opts.getRows) {
			return rs;
		}

		var rows = rs.getRows(true);
		if (!rows) {
			logger.error('select: rs.getRows() failed');
			return null;
		}

		return rows;
	};

	/**
	 * Get the number of rows that were affected by the last executed statement.
	 */
	Database.prototype.getRowsAffected = function () {
		try {
			var out = this.impl.GetRowsAffected();
			if (typeof out !== 'number') {
				logger.error('getRowsAffected: impl\'s return value is not a number:', out);
				out = +out; // hope for the best
			}
			return out;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getRowsAffected');
		}
		return null;
	};

	Database.prototype.getLastInsertRowId = function () {
		try {
			return this.impl.GetLastInsertRowId();
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getLastInsertRowId');
		}
		return null;
	};

	return Database;
})();

// Linko.pl - end of file 'Database.js'
// Linko.pl - start of file 'debug.js'

Linko.debug = {
	/**
	 * Attaches a getter and setter to a property. By default logs when the property is accessed.
	 * @param {Object} obj The object containing the property.
	 * @param {String} property The property to be watched.
	 * @param {Object} [callbacks]
	 * @param {Function} [callbacks.get] Called with the current value when the property is get. Defaults to logging.
	 * @param {Function} [callbacks.set] Called with the new value when the property is set. Defaults to logging.
	 */
	'watch': (function () {
		var values = {};
		return function (obj, property, callbacks) {
			var name = property;
			do {
				var uuid = Linko.util.uuid();
			} while (values.hasOwnProperty(uuid));

			var logCopy = function (name, a1, a2, val) {
				var copy = true;
				if (Linko.util.isCircular(val)) {
					copy = false;
				}
				else {
					try {
						val = Linko.util.copy(val);
					}
					catch (e) {
						copy = false;
					}
				}
				console.log(name, copy ? a1 : a2, val);
			};

			callbacks = Linko.util.extend({
				'get': function (val) {
					logCopy(name, '-->', '==>', val);
				},
				'set': function (val) {
					logCopy(name, '<--', '<==', val);
				}
			}, callbacks);

			obj.__defineGetter__(property, function () {
				try {
					Linko.util.callAll(callbacks.get, [values[uuid]]);
				}
				catch (e) {
					Linko.logger.error('Linko.debug.watch: get callback threw');
				}

				return values[uuid];
			});

			obj.__defineSetter__(property, function (val) {
				try {
					Linko.util.callAll(callbacks.set, [val]);
				}
				catch (e) {
					Linko.logger.error('Linko.debug.watch: set callback threw');
				}

				values[uuid] = val;
			});
		};
	})()
};

// Linko.pl - end of file 'debug.js'
// Linko.pl - start of file 'Device.js'

Linko.Device = (function () {
	var logger = Linko.log.getLogger('Linko.Device');

	var Device = function (impl) {
		if (impl !== false) {
			return this.construct.apply(this, arguments);
		}
	};

	Device.logger = logger;

	Device.prototype.construct = function (impl) {
		if (!impl) {
			logger.error('constructor: no impl');
			return null;
		}

		this.id   = null;
		this.name = null;
		this.impl = impl;

		this.storages   = [];
		this.attributes = {};

		this.capacity = {
			'total': 0,
			'free' : 0
		};

		this.db = {
			'cache' : {},
			'meta'  : null,
			'common': null,
			'sync'  : {
				'firstSync': null,
				'canceled': false
			}
		};
	};

	/**
	 * Set backend debug on or off.
	 * @param {Boolean} on
	 */
	Device.prototype.debug = function (on) {
		if (on === 'on' || on === 'off') {
			logger.warn('debug: parameter should be a Boolean, not a String');
			on = on === 'on';
		}
		return this.genericInvoke('Debug', on ? 'on' : 'off');
	};

	/**
	 * Get a database.
	 * @param {String} name Name of the database
	 * @return {Linko.Database|null}
	 */
	Device.prototype.getDatabase = function (name) {
		if (!name) {
			name = 'METABASE';
		}

		if (!this.db.cache[name]) {
			try {
				var dbImpl = this.impl.GetDatabase(name);
				if (!dbImpl) {
					logger.error('getDatabase: Failed to get database impl');
					return null;
				}
				var db = new Linko.Database(dbImpl);
				if (!db) {
					logger.error('getDatabase: Failed to create Linko.Database');
					return null;
				}
				this.db.cache[name] = db;
			}
			catch (e) {
				logger.error('getDatabase: exception:', e);
				return null;
			}
		}

		return this.db.cache[name];
	};

	/**
	 * Reset the metabase.
	 */
	Device.prototype.resetDatabase = function () {
		var result = this.genericInvoke('Database.Reset', '');
		if (result === '1') {
			this.db.cache['METABASE'] = null;
			return true;
		}

		return false;
	};

	/**
	 * @param {String} database
	 * @param {String} query
	 * @return {Linko.ResultSet|null}
	 */
	Device.prototype.executeQuery = function (database, query) {
		var db = this.getDatabase(database);
		if (!db) {
			logger.error('executeQuery: this.getDatabase failed');
			return null;
		}

		return db.execute(query);
	};

	/**
	 * Sync the metabase.
	 * @param {String} [action] /^(continue|full|force)$/
	 * @param {String} [what]   /^(all|video|audio|image)$/
	 */
	Device.prototype.syncMetabase = function (action, what) {
		if (!what) {
			what = 'all';
		}

		if (!action) {
			action = this.db.sync.canceled ? 'continue' : 'full';
		}

		if (!/^(all|video|audio|image)$/.test(what)) {
			logger.error('syncMetabase: invalid value for what:', what);
			what = 'all';
		}

		if (!/^(continue|full|force)$/.test(action)) {
			logger.error('syncMetabase: invalid value for action:', action);
			action = 'full';
		}

		var dl = this.downloadFile();
		if (!dl) {
			logger.error('syncMetabase: couldn\'t create Download object');
			return false;
		}

		logger.info('syncMetabase: starting sync...');
		dl.setName(this.getName());
		dl.setMimeType(what);
		dl.open('metasync:' + action);
		//dl.setCallback(function (args) {
		//	logger.debug('syncMetabase callback', arguments);
		//});
		dl.begin(null);

		this.db.sync.canceled = false;
		this.db.sync.firstSync = false;

		logger.info('syncMetabase: started');

		return true;
	};

	/**
	 * Has the device been synced.
	 * @return {Boolean}
	 */
	Device.prototype.isFirstSync = function () {
		if (this.db.sync.firstSync === null) {
			var settings = this.getSettings();
			this.db.sync.firstSync = true;

			/* If sync_last_date is found from db, device has been synced before */
			if (settings['sync_begin_date']) {
				this.db.sync.firstSync = false;
			}
		}

		return this.db.sync.firstSync;
	};

	/**
	 * Get settings from metabase.
	 * @return {Object} Key-value pairs
	 */
	Device.prototype.getSettings = function () {
		var db = this.getDatabase();
		if (!db) {
			logger.error('getSettings: no db');
			return null;
		}
		var rows = db.select({
			'cols': ['key', 'value'],
			'tables': ['settings']
		});
		if (!rows) {
			logger.error('getSettings: rs.getRows() failed');
			return null;
		}
		var out = {};
		rows.forEach(function (row) {
			out[row.key] = row.value;
		});
		return out;
	};

	Device.prototype.isSyncRequired = function () {
		if (this.getTotalSpace() === 0) {
			return false;
		}

		var self = this;
		var syncInProgress = Linko.util.any(Linko.taskMonitor.getActiveSyncs(), function (sync) {
			return sync.device !== self.getId();
		});

		if (syncInProgress) {
			return false;
		}

		var settings = this.getSettings();
		if (!settings) {
			logger.error('isSyncRequired: this.getSettings() failed');
			settings = {};
		}
		var syncBeginDate = settings['sync_begin_date'];
		var syncLastDate  = settings['sync_last_date'];

		if (!syncBeginDate) {
			return this.db.sync.firstSync = true;
		}
		this.db.sync.firstSync = false;

		if (!syncLastDate || syncBeginDate > syncLastDate) {
			return this.db.sync.canceled = true;
		}
		this.db.sync.canceled = false;

		var typeId = this.getTypeId();
		if (typeId === 64 || typeId === 65) {
			return false;
		}

		return false;
	};

	/**
	 * Was the last sync canceled?
	 * @return {Boolean}
	 */
	Device.prototype.isSyncCanceled = function () {
		if (this.db.sync.canceled === null) {
			var settings = this.getSettings();
			if (!settings) {
				logger.error('isSyncCanceled: this.getSettings() failed');
				return null;
			}
			var begin    = settings['sync_begin_date'];
			var last     = settings['sync_last_date'];

			if (begin && !last || begin > last) {
				this.db.sync.canceled = true;
			}
		}

		return this.db.sync.canceled;
	};

	/**
	 * Searches for name in the device's attribute '$managers'.
	 * @param {String} name
	 * @return {Boolean}
	 */
	Device.prototype.hasClass = function (name) {
		var classes = String(this.getAttribute('$managers')).split(',');
		return classes.indexOf(name) !== -1;
	};

	/**
	 * Send an error report.
	 * @param {String} email
	 */
	Device.prototype.sendErrorReport = function (email) {
		this.genericInvoke('System.Diagnose', email);
	};

	Device.prototype.setupDevice = function () {
		this.attributes.image = this.getAttribute('imageUrl');
	};

	Device.prototype.getStorages = function () {
		this.capacity = { total:0, free:0 };
		this.storages = [];

		var impls;
		try {
			impls = this.impl.GetStorages();
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getStorages');
			return this.storages;
		}

		if (!impls) {
			return this.storages;
		}

		for (var i = 0; i < impls.length; ++i) {
			var impl = null;
			try {
				impl = Linko.system.isMac ? impls[i] : impls.GetElement(i);
			}
			catch (e) {
				Linko.system.pluginError(e, logger, 'getStorages');
				continue;
			}

			var disk = new Linko.Storage(impl);

			this.capacity.total += disk.getTotalSpace();
			this.capacity.free  += disk.getFreeSpace();

			this.storages.push(disk);
		}

		return this.storages;
	};

	Device.prototype.getFreeSpace = function () {
		this.getStorages();
		return this.capacity.free;
	};

	Device.prototype.getTotalSpace = function () {
		this.getStorages();
		return this.capacity.total;
	};

	/**
	 * @param {String} name The attribute's name
	 * @return {String|null} The attribute's value
	 */
	Device.prototype.getAttribute = function (name) {
		if (this.attributes[name] === undefined) {
			try {
				this.attributes[name] = this.impl.GetAttribute(name);
			}
			catch (e) {
				Linko.system.pluginError(e, logger, 'getAttribute');
			}
		}

		return this.attributes[name];
	};

	/**
	 * Get a file's URL.
	 * @param {String} fileId
	 * @return {String}
	 */
	Device.prototype.getResource = function (fileId) {
		return this.genericInvoke('GetById', fileId);
	};

	Device.prototype.getApplicationIcon = function (iconId) {
		return this.genericInvoke('Application.GetIcon', iconId);
	};

	Device.prototype.getApplicationList = function () {
		var response = this.genericInvoke('Application.List', '');

		try {
			return JSON.parse(response);
		}
		catch (e) {
			logger.error('getApplicationList: response is invalid JSON: "' + response + '"');
		}
		return null;
	};

	/**
	 * @param {String} url
	 * @return {Boolean} true on success, false on failure
	 */
	Device.prototype.installApplication = function (url) {
		return !!+this.genericInvoke('Application.Install', url);
	};

	/**
	 * @param {String} appName
	 * @return {Boolean} true on success, false on failure
	 */
	Device.prototype.uninstallApplication = function (appName) {
		return !!+this.genericInvoke('Application.Uninstall', appName);
	};

	Device.prototype.getPlaylists = function () {
		var db = this.getDatabase();
		if (!db) {
			logger.error('getPlaylists: this.getDatabase() failed');
			return null;
		}
		var rows = db.select({
			'cols': ['files.id', 'files.name AS filename'],
			'tables': ['files'],
			'where': ['metatable = "playlist"', 'files.ext != "wpl"']
		});
		if (!rows) {
			logger.error('getPlaylists: db.select() failed');
			return null;
		}

		var self = this;
		return rows.map(function (row) {
			return new Linko.Playlist({
				'from': {
					'type':   'device',
					'device': self,
					'dbId':   row.id
				},
				'title':    row.filename,
				'filename': row.filename
			});
		});
	};

	/**
	 * @return {String} URL
	 */
	Device.prototype.getImage = function () {
		if (!this.attributes.image) {
			this.attributes.image = this.getAttribute('imageUrl');
		}
		return this.attributes.image;
	};

	Device.prototype.getName = function () {
		if (!this.name) {
			try {
				this.name = Linko.system.isMac ? this.impl.GetPropertyName() : this.impl.name;
			}
			catch (e) {
				Linko.system.pluginError(e, logger, 'getName');
			}
		}

		return this.name;
	};

	Device.prototype.getId = function () {
		if (!this.id) {
			try {
				this.id = Linko.system.isMac ? this.impl.GetPropertyId() : this.impl.id;
			}
			catch (e) {
				Linko.system.pluginError(e, logger, 'getId');
			}
		}

		return this.id;
	};

	Device.prototype.getTypeId = function () {
		if (!this.typeId) {
			try {
				this.typeId = Linko.system.isMac ? +this.impl.GetPropertyTypeId() : this.impl.typeId;
			}
			catch (e) {
				Linko.system.pluginError(e, logger, 'getTypeId');
			}
		}

		return this.typeId;
	};

	Device.prototype.getConnectionType = function () {
		return this.getAttribute('$connectionType');
	};

	Device.prototype.getConnectionMode = function () {
		return this.getAttribute('connectionMode');
	};

	Device.prototype.getPowerInfo = function () {
		return {
			'source': this.getAttribute('$powerSource'),
			'level' : this.getAttribute('$powerLevel')
		};
	};

	Device.prototype.getDisplayInfo = function () {
		return {
			'width' : this.getAttribute('display_width'),
			'height': this.getAttribute('display_height'),
			'colors': this.getAttribute('display_colors')
		};
	};

	Device.prototype.getSystemInfo = function () {
		return {
			'osName'         : this.getAttribute('os_name'),
			'osVersion'      : this.getAttribute('os_version'),
			'platform'       : this.getAttribute('platform_name'),
			'platformVersion': this.getAttribute('platform_version'),
			'firmwareVersion': this.getAttribute('$firmwareVersion')
		};
	};

	Device.prototype.getDRMInfo = function () {
		return {
			'secureclock'  : this.getAttribute('secureclock'),
			'secureclockEX': this.getAttribute('drm_secureclock'),
			'audio'        : {
				'omadrm' : this.getAttribute('media_drm_audio_omadrmsd'),
				'wmdrm'  : this.getAttribute('a_wmdrm'),
				'wmdrmpd': this.getAttribute('a_wmdrmpd')
			},
			'video'        : {
				'omadrm' : this.getAttribute('v_omadrmsd'),
				'wmdrm'  : this.getAttribute('v_wmdrm'),
				'wmdrmpd': this.getAttribute('v_wmdrmpd')
			}
		};
	};

	Device.prototype.getJavaInfo = function () {
		return {
			'midp': this.getAttribute('java_midp'),
			'clcd': this.getAttribute('java_clcd'),
			'jsr' : this.getAttribute('java_jsr')
		};
	};

	/**
	 * Only the largest storage is searched. If there is more than one storage
	 * with the largest size, then the first one returned by getStorages is used.
	 * @param {String} name
	 * @return {Linko.Folder|null}
	 */
	Device.prototype.getNamedFolder = function (name) {
		if (this.storages.length === 0) {
			this.getStorages();
		}

		/* PC device handling */
		if (this.getTypeId() === 64) {
			switch (name) {
			case 'audio':
				return this.storages[0];
			case 'image':
				return this.storages[2];
			case 'video':
				return this.storages[1];
			default:
				return null;
			}
		}

		var out = null;
		this.storages.forEach(function (disk) {
			if (out === null || out.getTotalSpace() < disk.getTotalSpace()) {
				out = disk;
			}
		});

		return out && out.getNamedFolder(name) || null;
	};

	Device.prototype.getRootFolders = function () {
		var out = [];

		var f = function (name, title) {
			var folder = this.getNamedFolder(name);
			if (folder) {
				out.push({ namedroot:name + '@', title:title, root:folder });
			}
		};

		f.call(this, 'audio', 'Music');
		f.call(this, 'video', 'Video');
		f.call(this, 'image', 'Pictures');
		f.call(this, 'photo', 'Photos');
		f.call(this, 'files', 'Files');
		f.call(this, 'root', 'Storage Media');

		return out;
	};

	/**
	 * @return {Linko.Download|null}
	 */
	Device.prototype.downloadFile = function () {
		try {
			return new Linko.Download(this.impl.DownloadFile());
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'downloadFile');
		}
		return null;
	};

	Device.prototype.genericInvoke = function (p1, p2) {
		try {
			return this.impl.GenericInvoke(p1, p2);
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'genericInvoke');
		}
		return null;
	};

	return Device;
})();

// Linko.pl - end of file 'Device.js'
// Linko.pl - start of file 'Computer.js'

Linko.Computer = (function () {
	var logger = Linko.log.getLogger('Linko.Computer');
	var Computer = function (impl) {
		return this.construct.apply(this, arguments);
	};

	Computer.prototype = new Linko.Device(false);

	Computer.prototype.getPlaylists = function () {
		var parent = Linko.Device.prototype.getPlaylists.apply(this, arguments) || [];
		var iTunes = Linko.iTunes.cache.getPlaylists() || [];
		iTunes = iTunes.map(function (list) {
			return new Linko.Playlist({
				'from': {
					'type': 'itunes',
					'itunesId': list.id
				},
				'title': list.name,
				'tracks': null,
				'trackCount': list.trackCount,
				'load': false
			});
		});
		return parent.concat(iTunes);
	};

	return Computer;
})();
// Linko.pl - end of file 'Computer.js'
// Linko.pl - start of file 'DeviceManager.js'

Linko.DeviceManager = (function () {
	var logger = Linko.log.getLogger('Linko.DeviceManager');
	var DeviceManager = function () {
		this.allDevices = [];
		this.devices    = [];
		this.computer   = null;
		this.impl       = null;

		logger.debug('constructor: Creating impl...');
		try {
			this.impl = Linko.system.create('DeviceManager');
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'constructor');
			return null;
		}

		if (!this.impl) {
			return null;
		}

		try {
			var self = this;
			var cb = function (/* ... */) {
				return deviceNotificationHandler.apply(self, arguments);
			};
			if (Linko.system.isMac) {
				this.impl.setDeviceNotificationCallback(cb);
			}
			else {
				this.impl.AttachEventHandler('onDeviceNotification', cb);
			}
			logger.debug('constructor: OK');
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'constructor');
		}
	};

	DeviceManager.logger = logger;

	var deviceNotificationHandler = function (status) {
		var group = status >> 16;
		var val   = status & 0xffff;

		if (group === 0) {
			group = val;
		}

		var fire = Linko.util.once(function () {
			Linko.system.fireEvent('onDeviceNotification', [group, val]);
		});

		if (group >= 5) {
			return void fire();
		}

		if (this.nohax) {
			this.updateDeviceList();
			return void fire();
		}

		// here be haxes
		var len = this.impl.devices.length;

		var self = this;
		var interval = setInterval(function () {
			self.updateDeviceList();
			if (self.devices.length < len) {
				clearInterval(interval);
				fire();
			}
		}, 200);
		setTimeout(function () {
			clearInterval(interval);
			fire();
		}, 2000);
	};

	DeviceManager.prototype.getDevices = function () {
		if (!this.impl) {
			logger.warn('getDevices: no impl');
			return [];
		}

		var deviceImpls = [];
		try {
			if (Linko.system.isMac) {
				deviceImpls = Linko.util.array(this.impl.getDevices());
			}
			else {
				var temp = this.impl.devices;
				if (temp) {
					for (var i = 0; i < temp.length; ++i) {
						deviceImpls.push(temp.GetElement(i));
					}
				}
			}
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getDevices');
		}

		logger.debug('getDevices: got ' + deviceImpls.length + ' device impls');

		var devices = [];
		deviceImpls.forEach(function (impl, i) {
			var device = i === 0 ? new Linko.Computer(impl) : new Linko.Device(impl);
			if (!device) {
				logger.warn('getDevices: Failed to create a device from impl');
				return;
			}
			var id = device.getId();
			if (/error|console/i.test(id)) {
				logger.error('getDevices: found ErrorConsole:', device);
				Linko.errorConsole = device;
				Linko.system.fireEvent('onError', [18, 'ErrorConsole: ' + device.getAttribute('description')]);
				return;
			}
			devices.push(device);
		});

		logger.debug('getDevices: got ' + devices.length + ' devices');

		return devices;
	};

	DeviceManager.prototype.updateDeviceList = function () {
		logger.debug('updateDeviceList: currently ' + this.devices.length + ' devices');

		this.devices = [];

		if (!this.impl) {
			logger.warn('updateDeviceList: no impl');
			return false;
		}

		this.allDevices = this.getDevices();

		if (this.allDevices.length === 0) {
			logger.error('updateDeviceList: device list is empty');
			this.devices  = [];
			this.computer = null;
			return false;
		}

		this.allDevices.forEach(function (device) {
			device.setupDevice();
		});

		this.computer = this.allDevices[0];
		this.devices  = this.allDevices.slice(1);

		return true;
	};

	DeviceManager.prototype.getDeviceById = function (id) {
		if (!this.impl) {
			logger.warn('getDeviceById: no impl');
			return null;
		}

		return Linko.util.find(this.allDevices, function (device) {
			if (device.getId() === id) {
				return device;
			}
		}) || null;
	};

	return DeviceManager;
})();

// Linko.pl - end of file 'DeviceManager.js'
// Linko.pl - start of file 'Download.js'

Linko.Download = (function () {
	var logger = Linko.log.getLogger('Linko.Download');
	var Download = function (impl) {
		if (!impl) {
			logger.error('Construct:', impl);
			return null;
		}
		this.impl = impl;

		// 0: real target device
		// 1: virtual target device
		this.targetDeviceType = 0;
	};

	// Set the callback for onProgress events.
	Download.prototype.setCallback = function (callback) {
		try {
			if (Linko.system.isMac) {
				this.impl.SetProgressCallback(callback);
			}
			else {
				this.impl.AttachEventHandler('onProgress', callback);
			}
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'setCallback');
		}
		return false;
	};

	/**
	 * @param {String} src
	 * @param {String} [options]    generic parameter for BES
	 * @param {String} [appOptions] generic parameter for BES
	 */
	Download.prototype.open = function (src, options, appOptions) {
		try {
			this.impl.Open(src, options || '', JSON.stringify(appOptions || ''));
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'open');
		}
		return false;
	};

	/**
	 * Get the target device's type.
	 * @return {Number} 0: real device, 1: virtual device
	 */
	Download.prototype.getTargetDeviceType = function () {
		return this.targetDeviceType;
	};

	Download.prototype.begin = function (id) {
		try {
			return this.impl.Begin(id);
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'begin');
		}
		return null;
	};

	// Called when the download has finished.
	Download.prototype.end = Linko.noop;

	Download.prototype.setRequestHeader = function (name, value) {
		// Not implemented on Windows, fails on Mac
		/*
		try {
			this.impl.SetHeader(name, value);
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'setRequestHeader');
		}
		*/
		return false;
	};

	/**
	 * Set the filename.
	 * @param {String} name
	 */
	Download.prototype.setName = function (name) {
		try {
			if (Linko.system.isMac) {
				this.impl.SetName(name);
			}
			else {
				this.impl.name = name;
			}
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'setName');
		}
		return false;
	};

	Download.prototype.setMimeType = function (mimeType) {
		try {
			if (Linko.system.isMac) {
				this.impl.SetMimeType(mimeType);
			}
			else {
				this.impl.mimeType = mimeType;
			}
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'setMimeType');
		}
		return false;
	};

	return Download;
})();

// Linko.pl - end of file 'Download.js'
// Linko.pl - start of file 'downloader.js'

/* The only function users need is Linko.downloader.start.
 *
 * To receive events, give a callback for 'onDownloadProgress' events
 * when calling Linko.system.init.
 *
 * A download item can have the following properties:
 * {
 *     'sourceDevice'    : Linko.Device  // required when 'type' !== 'external'
 *     'targetDevice'    : Linko.Device  // required unless given as the second argument to Linko.downloader.start
 *
 *     // If type is external, 'src' is required; otherwise, 'id' is required.
 *     'id'              : String file ID on 'sourceDevice'
 *     'src'             : String file URL
 *
 *     'type'            : String (playlist|siteload|file|external), defaults to 'file'
 *     'name'            : String   // filename
 *     'path'            : String
 *
 *     'playlistSourceId': String required when type is 'playlist'
 *     'playlistTargetId': String required when type is 'playlist'
 *
 *     'headers'         : Object   // HTTP request headers, experimental feature
 * }
 */

Linko.downloader = (function () {
	var logger = Linko.log.getLogger('Linko.downloader');

	var downloader = {
		'logger': logger,

		'start': function (items, device) {
			if (!items) {
				items = [];
			}
			else if (!Array.isArray(items)) {
				items = [items];
			}

			if (items.length === 0) {
				logger.debug('start: no items, noop');
				return;
			}

			logger.debug('start: items.length = ' + items.length);
			if (device) {
				items.forEach(function (item) {
					item.targetDevice = device;
				});
			}
			transferBegin(items);
		},

		'getNewBunchId': (function () {
			var next = 1;
			return function () {
				return next++;
			};
		})()
	};

	var transferBegin = function (items) {
		var go = function (i) {
			if (i >= items.length) {
				logger.debug('transferBegin: done');
				return;
			}
			var item = items[i];

			if (item.externalSource) {
				logger.warn('transferBegin: externalSource=true --> type=external');
				item.type = 'external';
			}

			try {
				logger.assert(item.targetDevice, 'item has no targetDevice');

				item.transfer = item.targetDevice.downloadFile();
				var targetDeviceId = item.targetDevice.getId();

				// Valid parameters:
				//   'path':      '...'
				//   'newthread': 'true'|'1'|'yes'|'false'  // default 'false'
				//   'cids':      'GUID,GUID,GUID,...' // content IDs for siteload, indicating what content to download

				var params = [];
				if (item.path) {
					params = params.concat('path', item.path);
				}
				if (item.cids) {
					logger.assert(Array.isArray(item.cids), 'item.cids is not an array:', item.cids);
					params = params.concat('cids', item.cids.join(','));
				}
				if (i < 2 && targetDeviceId === Linko.deviceManager.computer.getId()) {
					params = params.concat('newthread', '1');
				}

				params = params.join('\u0001');

				// HTTP request headers

				if (item.headers) {
					Linko.util.each(item.headers, function (header, value) {
						if (!item.transfer.setRequestHeader(header, value)) {
							logger.warn('transferBegin: Failed to set request header');
						}
					});
				}

				// open(), setName(), setMimeType(), begin()

				var deviceType = item.transfer.getTargetDeviceType();
				switch (deviceType) {
				case 0: // real target device
					if (item.type === 'playlist') {
						logger.assert(item.playlistSourceId, 'item has no playlistSourceId');
						logger.assert(item.playlistTargetId, 'item has no playlistTargetId');

						item.transfer.open('transfer:playlist:' + item.playlistSourceId + ':' + item.playlistTargetId, params, item.appOptions);
						if (item.name) {
							item.transfer.setName(item.name);
						}
						item.transfer.begin(downloader.getNewBunchId());
					}
					else if (item.type === 'siteload') {
						item.transfer.open('transfer:siteload:' + item.id, params, item.appOptions);
						if (item.name) {
							item.transfer.setName(item.name);
						}
						item.transfer.begin(item.id);
					}
					else if (item.type === 'external') {
						logger.assert(item.src, 'item has no src');

						item.transfer.open(item.src, params, item.appOptions);
						item.transfer.setName(item.name);
						item.transfer.setMimeType(Linko.util.getMediaType(item));
						item.transfer.begin();
					}
					else {
						var sourceDeviceId = item.sourceDevice.getId();
						logger.assert(sourceDeviceId, 'sourceDevice.getId() returned ' + sourceDeviceId);
						logger.assert(item.id, 'item has no id');

						item.transfer.open('transfer:file:' + sourceDeviceId + ':' + item.id, params, item.appOptions);
						item.transfer.begin();
					}
					break;
				case 1: // virtual target device
					if (!item.src && !item.external) {
						item.src = item.sourceDevice.getResource(item.id);
					}

					item.transfer.open(item, params, item.appOptions);
					item.transfer.setName(item.name);
					item.transfer.setMimeType(Linko.util.getMediaType(item));
					item.transfer.begin();
					break;
				default:
					throw 'invalid deviceType: ' + deviceType;
				}
			}
			catch (e) {
				logger.error('transferBegin: exception:', e);
				item.transfer = null;
				item.error = e;
			}
			logger.debug(Linko.util.sprintf('transferBegin: downloaded started for file %d/%d', i + 1, items.length));
			setTimeout(function () {
				go(i + 1);
			}, 100);
		};
		go(0);
	};

	return downloader;
})();

// Linko.pl - end of file 'downloader.js'
// Linko.pl - start of file 'environment.js'

Linko.environment = (function () {
	/**
	 * Information about the OS and browser.
	 */
	var environment = {};

	var logger = environment.logger = Linko.log.getLogger('Linko.environment');

	environment.data = {
		/**
		 * The browser name. /^(Camino|Chrome|Firefox|iCab|IE|Konqueror|Netscape|OmniWeb|Opera|Safari|iPhone\/iPod)$/
		 * @type {String|null}
		 */
		'browser': null,

		/**
		 * The browser version.
		 * @type {Number|null}
		 */
		'version': null,

		/**
		 * The OS. /^(Windows (2000|XP|Vista|7)|Mac(( Snow)? Leopard|Tiger)|Linux)$/
		 * @type {String}
		 */
		'os': null,

		/**
		 * Is the browser/OS combination supported. true/false/null
		 * @type {Boolean|null}
		 */
		'compatible': null,

		/**
		 * The language reported by the browser. For example en, fi, or null
		 * @type {String|null}
		 */
		'language': null
	};

	/**
	 * Initialize the environment info. Before this is called, the properties are null.
	 */
	environment.init = function () {
		environment.init = Linko.noop;

		environment.data.browser    = searchString(dataBrowser);
		environment.data.version    = searchVersion(navigator.userAgent) || searchVersion(navigator.appVersion);
		environment.data.os         = searchString(dataOS);
		environment.data.compatible = isCompatible(environment.data.os, environment.data.browser, environment.data.version);
		environment.data.language   = window.navigator && (window.navigator.language || window.navigator.browserLanguage || window.navigator.systemLanguage || window.navigator.userLanguage || '').replace(/-.*/, '') || null;

		if (typeof Linko.system === 'undefined') {
			Linko.system = {};
		}

		Linko.system.isMac = !!environment.data.os.match(/Mac/);
		Linko.system.isXP  = environment.data.os === 'Windows XP';
		Linko.system.isIE  = !document.addEventListener;
		Linko.system.isFF  = environment.data.browser === 'Firefox';

		logger.debug('init: browser    = ' + environment.data.browser);
		logger.debug('init: version    = ' + environment.data.version);
		logger.debug('init: os         = ' + environment.data.os);
		logger.debug('init: compatible = ' + environment.data.compatible);
		logger.debug('init: language   = ' + environment.data.language);
		logger.debug('init: Linko.system.isMac = ' + Linko.system.isMac);
		logger.debug('init: Linko.system.isXP  = ' + Linko.system.isXP);
		logger.debug('init: Linko.system.isIE  = ' + Linko.system.isIE);
		logger.debug('init: Linko.system.isFF  = ' + Linko.system.isFF);
	};

	var searchString = function (data) {
		for (var i = 0; i < data.length; ++i) {
			var dataString = data[i].string;
			var dataProp = data[i].prop;
			this.versionSearchString = data[i].versionSearch || data[i].identity;
			if (dataString) {
				if (dataString.indexOf(data[i].subString) !== -1) {
					return data[i].identity;
				}
			}
			else if (dataProp) {
				return data[i].identity;
			}
		}

		return '';
	};

	var searchVersion = function (dataString) {
		var index = dataString.indexOf(this.versionSearchString);
		if (index === -1) {
			return '';
		}

		return parseFloat(dataString.substring(index + this.versionSearchString.length + 1));
	};

	/**
	 * @return {Boolean} true if system is compatible, false if incompatible, null if not sure.
	 */
	var isCompatible = function (os, browser, version) {

		if (browser === 'Opera') {
			return false;
		}

		if (os === 'Windows XP') {
			if (browser === 'Firefox') {
				return true;
			}
			else if (browser === 'IE') {
				return true;
			}
			else if (browser === 'Chrome') {
				return true;
			}
			/*Windows XP and Safari 4 is supported*/
			else if (browser === 'Safari' && version === 4) {
				return true;
			}

			/*Other browsers or other versions may work*/
			return null;
		}
		else if (os === 'Windows Vista') {
			if (browser === 'Firefox') {
				return true;
			}
			else if (browser === 'IE') {
				return true;
			}
			else if (browser === 'Chrome') {
				return true;
			}
			/*Windows Vista and Safari 4 is supported*/
			else if (browser === 'Safari' && version === 4) {
				return true;
			}

			/*Other browsers or other versions may work*/
			return null;
		}
		else if (os === 'Windows 7') {
			if (browser === 'Firefox') {
				return true;
			}
			else if (browser === 'IE') {
				return true;
			}
			else if (browser === 'Chrome') {
				return true;
			}
			/*Windows 7 and Safari 4 is supported*/
			else if (browser === 'Safari' && version === 4) {
				return true;
			}

			/*Other browsers or other versions may work*/
			return null;
		}
		/*Both of these Mac's are Intel based*/
		else if (os === 'Mac Snow Leopard') {
			// Safari version requirement relaxed on 2011-03-10
			if (browser === 'Safari'/* && version === 4*/) {
				return true;
			}
			else if (browser === 'Chrome') {
				return true;
			}
			else if (browser === 'Firefox') {
				return true;
			}

			/*Other browsers or other versions may work*/
			return null;
		}
		else if (os === 'Mac Leopard') {
			return false;
		}
		else if (os === 'iPad') {
			return true;
		}

		/*Other platforms are not supported yet (includes PPC Mac's)*/
		return false;
	};

	var dataBrowser = [
		{
			string: navigator.userAgent,
			subString: 'Chrome',
			identity: 'Chrome'
		},
		{ 	string: navigator.userAgent,
			subString: 'OmniWeb',
			versionSearch: 'OmniWeb/',
			identity: 'OmniWeb'
		},
		{
			string: navigator.vendor,
			subString: 'Apple',
			identity: 'Safari',
			versionSearch: 'Version'
		},
		{
			prop: window.opera,
			identity: 'Opera'
		},
		{
			string: navigator.vendor,
			subString: 'iCab',
			identity: 'iCab'
		},
		{
			string: navigator.vendor,
			subString: 'KDE',
			identity: 'Konqueror'
		},
		{
			string: navigator.userAgent,
			subString: 'Firefox',
			identity: 'Firefox'
		},
		{
			string: navigator.vendor,
			subString: 'Camino',
			identity: 'Camino'
		},
		{ // for newer Netscapes (6+)
			string: navigator.userAgent,
			subString: 'Netscape',
			identity: 'Netscape'
		},
		{
			string: navigator.userAgent,
			subString: 'MSIE',
			identity: 'IE',
			versionSearch: 'MSIE'
		},
		{
			string: navigator.userAgent,
			subString: 'Gecko',
			identity: 'Mozilla',
			versionSearch: 'rv'
		},
		{  // for older Netscapes (4-)
			string: navigator.userAgent,
			subString: 'Mozilla',
			identity: 'Netscape',
			versionSearch: 'Mozilla'
		},
		{
			string: navigator.userAgent,
			subString: 'iPad',
			identity: 'iPad'
		}
	];
	var dataOS = [
		{
			string: navigator.userAgent,
			subString: 'Windows NT 5.1',
			identity: 'Windows XP'
		},
		{
			string: navigator.userAgent,
			subString: 'Windows NT 6.0',
			identity: 'Windows Vista'
		},
		{
			string: navigator.userAgent,
			subString: 'Windows NT 6.1',
			identity: 'Windows 7'
		},
		{
			string: navigator.userAgent,
			subString: 'Windows NT 5.0',
			identity: 'Windows 2000'
		},
		/*This is for Safari and Chrome*/
		{
			string: navigator.userAgent,
			subString: 'Intel Mac OS X 10_6',
			identity: 'Mac Snow Leopard'
		},
		/*This is for Firefox*/
		{
			string: navigator.userAgent,
			subString: 'Intel Mac OS X 10.6',
			identity: 'Mac Snow Leopard'
		},
		/*This is for Opera on Intel Mac's, IT DOESN'T GIVE OS X VERSION! (so we allow all versions event Tigers to be Snow leopard)*/
//		{
//			string: navigator.userAgent,
//			subString: 'Intel',
//			identity: 'Mac Snow Leopard'
//		},
		{
			string: navigator.userAgent,
			subString: 'Intel Mac OS X 10_5',
			identity: 'Mac Leopard'
		},
		{
			string: navigator.userAgent,
			subString: 'Intel Mac OS X 10_4',
			identity: 'Mac Tiger'
		},
		{
			string: navigator.userAgent,
			subString: 'iPhone',
			identity: 'iPhone/iPod'
		},
		{
			string: navigator.platform,
			subString: 'Linux',
			identity: 'Linux'
		}
	];

	return environment;
})();

// Linko.pl - end of file 'environment.js'
// Linko.pl - start of file 'Exception.js'

Linko.Exception = function (code, message) {
	this.code    = code;
	this.message = message;
};

Linko.Exception.prototype.toString = function () {
	return Linko.util.sprintf('Error %d - %s', this.code, this.message);
};

Linko.PluginException = function (error) {
	if (!error || typeof error !== 'object') {
		error = String(error);
		try {
			error = JSON.parse(error);
		}
		catch (e) {
			error = {
				'message': error
			};
		}
	}

	if (error.description) {
		try {
			error.description = JSON.parse(error.description);
		}
		catch (e) {
			error.description = {
				'message': error.description
			};
		}
	}

	this.code = 0;

	if (!this.code) {
		this.code = +Linko.util.getDef(this.code, error, 'description', 'error', 'value') >>> 0;
	}
	if (!this.code) {
		this.code = +Linko.util.getDef(this.code, error, 'error', 'value') >>> 0;
	}
	if (!this.code) {
		this.code = +Linko.util.getDef(this.code, error, 'number') >>> 0;
	}
	if (!this.code) {
		this.code = +Linko.util.getDef(this.code, error, 'code');
	}

	this.message = [
		Linko.util.get(error, 'error', 'description', 'message'),
		Linko.util.get(error, 'error', 'message'),
		Linko.util.get(error, 'message')
	].filter(Linko.id).join(' - ');

	this.parent = new Linko.Exception(this.code, this.message);
};

Linko.PluginException.prototype.toString = function () {
	return Linko.util.sprintf('%#010x - %s', this.code, this.message);
};

// Linko.pl - end of file 'Exception.js'
// Linko.pl - start of file 'File.js'

Linko.File = (function () {
	var logger = Linko.log.getLogger('Linko.File');
	var File = function (impl) {
		this.impl = impl;
	};

	File.prototype.getName = function () {
		try {
			return this.impl.name;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getName');
		}
		return null;
	};

	/**
	 * Get the last modified date.
	 * @return {String} date
	 */
	File.prototype.getDate = function () {
		try {
			return this.impl.date;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getDate');
		}
		return null;
	};

	File.prototype.getSrc = function () {
		try {
			return this.impl.src;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getSrc');
		}
		return null;
	};

	/**
	 * Get the file's size in bytes.
	 * @return {Number} file size in bytes
	 */
	File.prototype.getSize = function () {
		try {
			return this.impl.size;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getSize');
		}
		return null;
	};

	File.prototype.getMetaData = function () {
		try {
			return this.impl.GetMetaData();
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getMetaData');
		}
		return null;
	};

	return File;
})();

// Linko.pl - end of file 'File.js'
// Linko.pl - start of file 'Folder.js'

Linko.Folder = (function () {
	var logger = Linko.log.getLogger('Linko.Folder');
	var Folder = function (impl) {
		this.impl     = impl;
		this.contents = {};
	};

	Folder.prototype.getName = function () {
		try {
			return this.impl.name;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getName');
		}
		return null;
	};

	Folder.prototype.getDate = function () {
		try {
			return this.impl.date;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getDate');
		}
		return null;
	};

	var getContents = function (impl) {
		var out = {
			'files'  : [],
			'folders': []
		};

		try {
			var items = impl.GetItems();

			for (;;) {
				var elem = items.Next();

				if (!elem) {
					return out;
				}

				if (elem.type === 'Folder') {
					out.folders.push(new Linko.Folder(elem));
				}
				else {
					out.files.push(new Linko.File(elem));
				}
			}
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getContents');
		}
		return out;
	};

	Folder.prototype.getFiles = function (start, count) {
		if (!this.contents.files) {
			this.contents = getContents(this.impl);
		}

		if (!start || !count) {
			return this.contents.files;
		}

		return this.contents.files.slice(start, start + count);
	};

	Folder.prototype.getFolders = function (start, count) {
		if (!this.contents.folders) {
			this.contents = getContents(this.impl);
		}

		if (!start || !count) {
			return this.contents.folders;
		}

		return this.contents.folders.slice(start, start + count);
	};

	return Folder;
})();

// Linko.pl - end of file 'Folder.js'
// Linko.pl - start of file 'image.js'

Linko.image = (function () {
	var getFormatCode = function (format) {
		format = String(format).toLowerCase();
		return ['bmp','gif','jpeg','png','tif'].indexOf(format) === -1 ? 'jpeg' : format;
	};

	var getMethodCode = function (string) {
		var code = ['aspect', 'auto', 'vertical', 'horizontal', 'fit', 'aspect;fill', 'liquid'].indexOf(String(string).toLowerCase());
		if (code !== -1) {
			return code;
		}
		else {
			Linko.logger.error('Linko.image: invalid method: "' + string + '"');
			return 0;
		}
	};

	var getAnchorCode = function (string) {
		var code = ['center', 'top', 'right', 'bottom', 'left'].indexOf(String(string).toLowerCase());
		if (code !== -1) {
			return code;
		}
		else {
			Linko.logger.error('Linko.image: invalid anchor: "' + string + '"');
			return 0;
		}
	};

	var image = {
		/**
		 * Generic transform.
		 * @param {String}        src
		 * @param {Object}        [options]
		 * @param {Number}        [options.width]         Target width in pixels - default is source width
		 * @param {Number}        [options.height]        Target height in pixels - default is source height
		 * @param {String|Number} [options.method=0]      One of [0,1,2,3,4,5,6] or ['aspect','auto',vertical','horizontal','fit','aspect;fill','liquid']
		 * @param {String|Number} [options.anchor]        One of [0,1,2,3,4] or ['center','top','right','bottom','left']
		 * @param {String}        [options.format='jpeg'] One of ['bmp','gif','jpeg','png','tif']
		 * @param {String}        [options.fill]          A CSS color
		 * @return {String} URL of the transformed image
		 */
		'transform': function (src, options) {
			var params = Linko.util.extend({
				'src': src
			}, options);

			if (typeof params.method === 'string') {
				params.method = getMethodCode(params.method);
			}

			if (typeof params.anchor === 'string') {
				params.anchor = getAnchorCode(params.anchor);
			}

			params.format = getFormatCode(params.format);

			return Linko.system.getMediaServerURL() + 'transform/image' + Linko.util.makeQueryString(params, true);
		},

		/**
		 * @param {String} type /^(?:fit|aspect;fill|aspect|liquid)$/
		 * @param {String} src
		 * @param {Number} width
		 * @param {Number} height
		 * @param {String} color
		 * @param {String} format /^(?:bmp|gif|jpeg|png|tif)$/
		 * @return {String} URL
		 */
		'scale': function (type, src, width, height, color, format) {
			if (Linko.system.isMac && type === 'liquid') {
				Linko.logger.warn('Linko.image.scale: Mac plugin can\'t do liquid rescaling; using "fit" instead');
				type = 'fit';
			}

			var params = {
				'src'   : src,
				'method': getMethodCode(type),
				'format': getFormatCode(format),
				'fill'  : color
			};

			if (typeof height === 'number' || typeof height === 'string') {
				params.height = height;
			}

			if (typeof width === 'number' || typeof width === 'string') {
				params.width = width;
			}

			return Linko.system.getMediaServerURL() + 'transform/image' + Linko.util.makeQueryString(params, true);
		},

		/**
		 * @param {String} type /^(vertical|horizontal|auto)$/
		 * @param {String} src
		 * @param {Number} width
		 * @param {Number} height
		 * @param {String} anchor /^(left|bottom|right|top|center)$/
		 * @param {String} format /^(bmp|gif|jpeg|png|tif)$/
		 * @return {String} URL
		 */
		'crop': function (type, src, width, height, anchor, format) {
			var params = {
				'src'   : src,
				'method': getMethodCode(type),
				'format': getFormatCode(format),
				'anchor': getAnchorCode(anchor),
				'fill'  : color
			};

			if (typeof height === 'number' || typeof height === 'string') {
				params.height = height;
			}

			if (typeof width === 'number' || typeof width === 'string') {
				params.width = width;
			}

			return Linko.system.getMediaServerURL() + 'transform/image' + Linko.util.makeQueryString(params, true);
		}
	};

	return image;
})();

// Linko.pl - end of file 'image.js'
// Linko.pl - start of file 'locale/common.js'

Linko.locale = {
	'current': 'en'
};

// Linko.pl - end of file 'locale/common.js'
// Linko.pl - start of file 'locale/get.js'

Linko.locale.gets = {};

Linko.locale.get = function (key, locale) {
	return Linko.util.get(Linko.locale.gets, [locale || Linko.locale.current, key]) || Linko.util.get(Linko.locale.gets, ['en', key]);
};

// Linko.pl - end of file 'locale/get.js'
// Linko.pl - start of file 'locale/date.js'

Linko.locale.date = function (date, options) {
	if (!date) {
		date = new Date();
	}
	if (!options) {
		options = {};
	}

	var params = {
		'day':       date.getDay(),
		'daySuffix': '',
		'month':     date.getMonth() + 1,
		'year':      date.getFullYear()
	};

	var format = '';
	switch (options.format) {
	case 'long':
		format = Linko.locale.get('dateFormatText');
		params.month = (Linko.locale.get('months') || [])[params.month - 1];
		break;
	case 'short':
		format = Linko.locale.get('dateFormatText');
		params.month = (Linko.locale.get('shortMonths') || [])[params.month - 1];
		break;
	case 'numeric':
	default:
		format = Linko.locale.get('dateFormatNumeric');
		break;
	}

	params.daySuffix = [null, 'st', 'nd', 'rd'][params.day % 10] || 'th';

	Linko.util.each(params, function (key, value) {
		format = format.replace(new RegExp('%' + key.replace(/(\W)/g, '\\$1') + ';', 'g'), value);
	});

	if (options.weekday) {
		if (options.format === 'long') {
			format = (Linko.locale.get('days') || [])[params.day - 1] + ' ' + format;
		}
		else if (options.format === 'short') {
			format = (Linko.locale.get('shortDays') || [])[params.day - 1] + ' ' + format;
		}
	}

	return format;
};

// Linko.pl - end of file 'locale/date.js'
// Linko.pl - start of file 'locale/get-en.js'

Linko.locale.gets['en'] = {
	'languageName': ['English', 'In English'],
	'months': [
		'January',
		'February',
		'March',
		'April',
		'May',
		'June',
		'July',
		'August',
		'September',
		'October',
		'November',
		'December'
	],
	'shortMonths': [
		'Jan',
		'Feb',
		'Mar',
		'Apr',
		'May',
		'Jun',
		'Jul',
		'Aug',
		'Sep',
		'Oct',
		'Nov',
		'Dec'
	],
	'days': [
		'Monday',
		'Tuesday',
		'Wednesday',
		'Thursday',
		'Friday',
		'Saturday',
		'Sunday'
	],
	'shortDays': [
		'Mon',
		'Tue',
		'Wed',
		'Thu',
		'Fri',
		'Sat',
		'Sun'
	],
	'timeUnits': [
		[
			'second',
			'minute',
			'hour',
			'day',
			'week',
			'month',
			'year'
		],
		[
			'seconds',
			'minutes',
			'hours',
			'days',
			'weeks',
			'months',
			'years'
		]
	],
	'dateFormatText':    '%month; %day;%daySuffix;, %year;',
	'dateFormatNumeric': '%month;/%day;/%year;'
};

// Linko.pl - end of file 'locale/get-en.js'
// Linko.pl - start of file 'locale/get-fi.js'

Linko.locale.gets['fi'] = {
	'languageName': ['suomi', 'suomeksi'],
	'months': [
		'tammikuu',
		'helmikuu',
		'maaliskuu',
		'huhtikuu',
		'toukokuu',
		'kesäkuu',
		'heinäkuu',
		'elokuu',
		'syyskuu',
		'lokakuu',
		'marraskuu',
		'joulukuu'
	],
	'shortMonths': [
		'tammi',
		'helmi',
		'maalis',
		'huhti',
		'touko',
		'kesä',
		'heinä',
		'elo',
		'syys',
		'loka',
		'marras',
		'joulu'
	],
	'days': [
		'maanantai',
		'tiistai',
		'keskiviikko',
		'torstai',
		'perjantai',
		'lauantai',
		'sunnuntai'
	],
	'shortDays': [
		'ma',
		'ti',
		'ke',
		'to',
		'pe',
		'la',
		'su'
	],
	'timeUnits': [
		[
			'sekunti',
			'minuutti',
			'tunti',
			'päivä',
			'viikko',
			'kuukausi',
			'vuosi'
		],
		[
			'sekuntia',
			'minuuttia',
			'tuntia',
			'päivää',
			'viikkoa',
			'kuukautta',
			'vuotta'
		]
	],
	'dateFormatText':    '%day;. %month;, %year;',
	'dateFormatNumeric': '%day;.%month;.%year;'
};

// Linko.pl - end of file 'locale/get-fi.js'
// Linko.pl - start of file 'locale/gettext.js'

Linko.locale.gettexts = {};

Linko.locale.gettext = function (str, params, count) {
	if (!params) {
		params = {};
	}

	if (Array.isArray(str)) {
		if (arguments.length > 2) {
			var n = typeof count === 'string' && typeof params[count] !== 'undefined' ? params[count] : count;
			str = Linko.locale.ngettext(str, n);
		}
		else {
			var tail = Linko.util.array(arguments).slice(1);
			return str.map(function (val) {
				return Linko.locale.gettext.apply(null, [val].concat(tail));
			});
		}
	}

	var msgKey = str.replace(/{'/g, '{');
	var msg = Linko.util.get(Linko.locale.gettexts, [Linko.locale.current, msgKey]);
	if (msg !== undefined && msg !== '') {
		(String(str).match(/{'[^{}]*}/g) || []).forEach(function (key) {
			key = key.slice(2, key.length - 1);
			msg = msg.replace(new RegExp('{' + key + '}'), '{\'' + key + '}');
		});
		str = msg;
	}


	Linko.util.each(params, function (key, value) {
		str = str.replace(new RegExp('\\{\\\'' + key + '\\}', 'g'), Linko.util.sprintf('%\'d', value))
		         .replace(new RegExp('\\{' + key + '\\}', 'g'), value);
	});

	return str;
};

Linko.locale.ngettext = function (strs, count) {
	return strs[count === 1 ? 0 : 1] || strs[0];
};

// Linko.pl - end of file 'locale/gettext.js'
// Linko.pl - start of file 'MD5Hash.js'

Linko.initQueue.push(function () {
	var logger = Linko.log.getLogger('Linko.MD5Hash');

	// The Mac version is so different that it's cleaner to keep completely
	// separate instead of creating an if-else jungle of isMacs.
	if (Linko.system.isMac) {
		Linko.MD5Hash = function () {
			this.content = '';
		};

		Linko.MD5Hash.prototype.getDigest = function () {
			try {
				var dazzler = Linko.system.getDazzler();
				var util = dazzler.util();
				var hash = util.MD5Hash(this.content);
				this.content = '';
				return hash;
			}
			catch (e) {
				Linko.system.pluginError(e, logger, 'getDigest');
				return null;
			}
		};

		Linko.MD5Hash.prototype.update = function (str) {
			this.content += str;
		};

		Linko.MD5Hash.prototype.init = function () {
			this.content = '';
		};
	}
	else {
		/**
		 * An implementation of MD5.
		 *
		 * To calculate an MD5 hash, do:
		 *     var md5 = new Linko.MD5Hash();
		 *     md5.init();
		 *     md5.update('abcde');
		 *     md5.update('fghij');
		 *     md5.update('klmno');
		 *     md5.update('pqrst');
		 *     md5.update('uvxyz');
		 *     var hash = md5.getDigest();
		 *
		 * Note that getDigest() resets the internal state, so the following does *not* work:
		 *     var md5 = new Linko.MD5Hash();
		 *     md5.update('123');
		 *     alert(md5.getDigest());
		 *     md5.update('456');
		 *     alert(md5.getDigest());
		 *     md5.update('789');
		 *     alert(md5.getDigest());
		 *     var hash = md5.getDigest();
		 *
		 * @param {Object} [impl]
		 */
		Linko.MD5Hash = function () {
			var impl = Linko.system.create('MD5Hash');

			if (!impl) {
				logger.error('constructor: no impl');
				return null;
			}

			this.impl = impl;
		};

		/**
		 * Calculates the MD5 hash and resets the internal state.
		 */
		Linko.MD5Hash.prototype.getDigest = function () {
			try {
				return this.impl.Finalize();
			}
			catch (e) {
				Linko.system.pluginError(e, logger, 'getDigest');
			}
			return null;
		};

		Linko.MD5Hash.prototype.update = function (str) {
			try {
				this.impl.Update(str);
				return true;
			}
			catch (e) {
				Linko.system.pluginError(e, logger, 'update');
			}
			return false;
		};

		/**
		 * Reset the internal state to an empty string.
		 */
		Linko.MD5Hash.prototype.init = function () {
			try {
				this.impl.Finalize();
				return true;
			}
			catch (e) {
				Linko.system.pluginError(e, logger, 'init');
			}
			return false;
		};
	}
});

// Linko.pl - end of file 'MD5Hash.js'
// Linko.pl - start of file 'player/common.js'

Linko.player = {};

Linko.player.normalizePlayerName = function (name) {
	switch (String(name).toLowerCase()) {

	case 'dazz':
	case 'dazzplayer':
	case 'shiny':
	case 'shinyplayer':
		return 'DazzPlayer';

	case 'fp':
	case 'flowplayer':
	case 'flash':
		return 'Flowplayer';

	case 'vlc':
	case 'videolanclient':
		return 'VLC';

	case 'wmp':
	case 'wmplayer':
	case 'windowsmediaplayer':
		return 'WindowsMediaPlayer';

	case 'qt':
	case 'quicktime':
		return 'QuickTime';

	case 'yt':
	case 'youtube':
		return 'YouTube';

	case 'html5':
		return 'HTML5';

	case 'sl':
	case 'silverlight':
		return 'Silverlight';

	default:
		Linko.log.getLogger('Linko.player').debug('Linko.player.normalizePlayerName: unrecognized player name:', name);
		return null;
	}
};

Linko.player.states = {
	'UNINITIALIZED': 0,
	'STOPPED':       1,
	'BUFFERING':     2,
	'PLAYING':       3,
	'PAUSED':        4,
	'CLIP_ENDED':    5
};

// Linko.pl - end of file 'player/common.js'
// Linko.pl - start of file 'player/Flowplayer.js'

Linko.player.Flowplayer = (function () {
	var states = Linko.player.states;
	var instanceCount = 0;

	// pasta from http://code.google.com/p/doctype/wiki/ArticleDetectFlash
	var supported = function () {
		var getFlashVersion = function (desc) {
			var matches = desc.match(/\d+/g);
			matches.length = 3;
			return matches.join('.');
		};

		var hasFlash = false;
		var flashVersion = '';

		if (navigator.plugins && navigator.plugins.length) {
			var plugin = navigator.plugins['Shockwave Flash'];
			if (plugin) {
				hasFlash = true;
				if (plugin.description) {
					flashVersion = getFlashVersion(plugin.description);
				}
			}

			if (navigator.plugins['Shockwave Flash 2.0']) {
				hasFlash = true;
				flashVersion = '2.0.0.11';
			}
		}
		else if (navigator.mimeTypes && navigator.mimeTypes.length) {
			var mimeType = navigator.mimeTypes['application/x-shockwave-flash'];
			hasFlash = mimeType && mimeType.enabledPlugin;
			if (hasFlash) {
				flashVersion = getFlashVersion(mimeType.enabledPlugin.description);
			}
		}
		else {
			try {
				// Try 7 first, since we know we can use GetVariable with it
				var ax = new ActiveXObject('ShockwaveFlash.ShockwaveFlash.7');
				hasFlash = true;
				flashVersion = getFlashVersion(ax.GetVariable('$version'));
			}
			catch (e) {
				// Try 6 next, some versions are known to crash with GetVariable calls
				try {
					var ax = new ActiveXObject('ShockwaveFlash.ShockwaveFlash.6');
					hasFlash = true;
					flashVersion = '6.0.21';  // First public version of Flash 6
				}
				catch (e) {
					try {
						// Try the default activeX
						var ax = new ActiveXObject('ShockwaveFlash.ShockwaveFlash');
						hasFlash = true;
						flashVersion = getFlashVersion(ax.GetVariable('$version'));
					}
					catch (e) {
						// No flash
					}
				}
			}
		}

		return hasFlash;
	};

	var logger = Linko.log.getLogger('Linko.player.Flowplayer');

	var Flowplayer = function (force) {
		if (!force && !supported()) {
			throw 'no flash';
		}

		this.impl        = null;
		this.initialized = false;

		this.es = new Linko.EventSource(['ready', 'error']);
	};

	Flowplayer.logger = logger;

	Flowplayer.prototype = {
		'name': 'Flowplayer',

		'_willFail': function (clip) {
			return /\.(?:wma|wmv|mov|mp4)$/.test(Linko.util.get(clip, 'url'));
		},

		'init': function (options) {
			if (this.initialized) {
				return;
			}

			if (this._willFail(Linko.util.get(options, 'clip'))) {
				return void this.es.dispatchEvent('error');
			}

			this.closed = false;

			this.instanceNumber = ++instanceCount;

			this.container    = options.container || null;
			this.width        = options.width || 0;
			this.height       = options.height || 0;
			this.videoEnabled = !!(this.width && this.height);

			if (!this.container) {
				this.ownContainer = true;
				this.container = document.createElement('div');
				document.body.appendChild(this.container);
			}

			this.container.setAttribute('id', 'flowplayer-container-' + this.instanceNumber);
			if (this.ownContainer && !this.videoEnabled) {
				Linko.util.hideElement(this.container);
			}

			var self  = this;
			var alive = false;

			var clipURL = Linko.util.getDef(null, options, 'clip', 'url');
			var autoPlay = !!clipURL && !options.noAutoPlay;

			window.setTimeout(function () {
				self.impl = $f(self.container.getAttribute('id'), {
					'src': Linko.conf.flowplayer.url,
					'width' : String(self.width),
					'height': String(self.height),
					'allowfullscreen'  : 'true',
					'allowscriptaccess': 'always'
				}, {
					'clip': {
						'url':  clipURL,
						'autoPlay': autoPlay,
						'autoBuffering': true
					},
					'plugins': {
						'controls': null,
						'audio': {
							'url': Linko.conf.flowplayer.plugins.audio
						}
					},
					'onError': function () {
						logger.error('impl onError:', arguments);
						self.es.dispatchEvent('error');
						self.close();
					},
					'onLoad': function () {
						logger.info('impl onLoad');
						alive = true;
						self.initialized = true;
						self.state = states.STOPPED;
						self.es.dispatchEvent('ready');
						if (autoPlay) {
							self.play(options.clip);
						}
						else {
							self.stop();
						}
						if (options.position) {
							setTimeout(function () {
								self.setPosition(options.position);
							}, 500);
						}
					}
				});
			}, 50);

			setTimeout(function () {
				if (!alive && !self.closed) {
					logger.error('init: Not alive after 5000ms, giving up');
					self.es.dispatchEvent('error', [true]);
				}
			}, 5000);
		},

		'_useDuration': function (duration) {
			var realGetDuration = this.getDuration;
			this.getDuration = function () {
				return duration;
			};
			var self = this;
			this._restoreDuration = function () {
				self.getDuration = realGetDuration;
			};
		},

		'_restoreDuration': Linko.noop,

		'play': function (clip) {
			this._restoreDuration();

			if (typeof clip === 'string') {
				clip = { 'url':clip };
			}
			if (this._willFail(clip)) {
				this.es.dispatchEvent('error');
				return false;
			}
			if (typeof clip.duration === 'number') {
				logger.debug('play: Using duration given with clip:', clip.duration);
				this._useDuration(clip.duration / 1000);
			}
			this.state = states.PLAYING;
			try {
				this.impl.play(clip.url);
				return true;
			}
			catch (e) {
				return false;
			}
		},

		'stop': function () {
			this.state = states.STOPPED;
			try {
				this.impl.stop();
				return true;
			}
			catch (e) {
				return false;
			}
		},

		'pause': function () {
			this.state = states.PAUSED;
			try {
				this.impl.pause();
				return true;
			}
			catch (e) {
				return false;
			}
		},

		'resume': function () {
			this.state = states.PLAYING;
			try {
				this.impl.resume();
				return true;
			}
			catch (e) {
				return false;
			}
		},

		'isPaused': function () {
			try {
				return !!this.impl.isPaused();
			}
			catch (e) {
				return null;
			}
		},

		// only for debugging
		'getImplState': function () {
			try {
				return this.impl.getState();
			}
			catch (e) {
				return null;
			}
		},

		'getState': function () {
			if (!this.initialized) {
				return states.UNINITIALIZED;
			}
			return this.state;
		},

		'getDuration': function () {
			try {
				var clip = this.impl.getClip();
				return clip && clip.fullDuration || this.getStatus().bufferEnd;
			}
			catch (e) {
				return 0;
			}
		},

		'getPosition': function () {
			try {
				return this.impl.getTime() / (this.getDuration() || 1) * 100 || 0;
			}
			catch (e) {
				return null;
			}
		},

		'setPosition': function (percent) {
			this.state = states.PLAYING;
			try {
				this.impl.seek(this.getDuration() * percent / 100);
				return true;
			}
			catch (e) {
				return false;
			}
		},

		'getVolume': function () {
			try {
				return this.impl.getVolume();
			}
			catch (e) {
				return null;
			}
		},

		'setVolume': function (vol) {
			try {
				this.impl.setVolume(vol);
				return true;
			}
			catch (e) {
				return false;
			}
		},

		'getBufferStart': function () {
			try {
				return this.getStatus().bufferStart / (this.getDuration() || 1) * 100;
			}
			catch (e) {
				return null;
			}
		},

		'getBufferEnd': function () {
			try {
				return this.getStatus().bufferEnd / (this.getDuration() || 1) * 100;
			}
			catch (e) {
				return null;
			}
		},

		'getFullscreen': function () {
			try {
				return !!this.impl.isFullscreen();
			}
			catch (e) {
				return null;
			}
		},

		'setFullscreen': function (bool) {
			return false;
		},

		'mute': function () {
			try {
				this.impl.mute();
				return true;
			}
			catch (e) {
				return false;
			}
		},

		'unmute': function () {
			try {
				this.impl.unmute();
				return true;
			}
			catch (e) {
				return false;
			}
		},

		'isMute': function () {
			try {
				return !!this.getStatus().muted;
			}
			catch (e) {
				return null;
			}
		},

		'getStatus': function () {
			var status = {
				'bufferStart': 0,
				'bufferEnd':   0,
				'state':       0,
				'time':        0,
				'muted':       false,
				'volume':      0
			};
			try {
				Linko.util.extend(status, this.impl.getStatus());
			}
			catch (e) {}
			return status;
		},

		'close': function () {
			this.closed = true;
			this.initialized = false;
			var self = this;
			Linko.util.try_(
				function () {
					try {
						self.impl.unload();
					}
					catch (e) {}
					self.impl = null;
				},
				Linko.noop,
				function () {
					$(self.container)[self.ownContainer ? 'remove' : 'empty']();
				}
			);
		}
	};

	return Flowplayer;
})();

// Linko.pl - end of file 'player/Flowplayer.js'
// Linko.pl - start of file 'player/Player.js'

Linko.player.Player = (function () {
	var states = Linko.player.states;

	var logger = Linko.log.getLogger('Linko.player.Player');

	var Player = function (options) {
		if (!options) {
			options = {};
		}

		this.container       = options.container;
		this.width           = options.width || 0;
		this.height          = options.height || 0;
		this.genericInterval = null;
		this.state           = states.STOPPED;
		this.wantedState     = null;
		this.methodQueue     = [];
		this.activePlayer    = null;
		this.playlist        = null;
		this.volume          = 30;
		this.shuffle         = false;
		this.repeat          = false;
		this.mute            = false;
		this.seekedAt        = null;
		this.stoppedAt       = null;
		this.lastChange      = {
			'state':       null,
			'bufferStart': null,
			'bufferEnd':   null,
			'position':    null
		};

		this.es = new Linko.EventSource([
			'clipStart', 'clipEnd',
			'stop', 'pause', 'resume',
			'error', 'noPlayer',
			'mute',
			'volumeChange', 'stateChange', 'positionChange',
			'bufferStartChange', 'bufferEndChange',
			'shuffleChange', 'repeatChange',
			'generic'
		]);

		var self = this;
		this.es.addEventListener(-1, 'clipEnd', function () {
			//logger.info('Clip ended, calling playlist.next()');
			self.playlist.next(self.shuffle, self.repeat);
		});

		this.es.addEventListener(-1, '*', function () {
			var level = 'trace';
			if (['clipStart','clipEnd','stop','pause','resume','error','mute','stateChange','shuffleChange','repeatChange'].indexOf(arguments[0]) !== -1) {
				level = 'debug';
			}
			logger[level]('Player event "' + arguments[0] + '"', arguments[1]);
		});

		this.setPlaylist(options.playlist || new Linko.Playlist());
		setTimeout(function () {
			self.loadConfig();
		}, 0);
//		this.loadPlayer();
	};

	Player.logger = logger;

	var checkNumber = function (n, min, max) {
		if (typeof n !== 'number' || !isFinite(n) || isNaN(n)) {
			return 0;
		}
		if (typeof min === 'number' && n < min) {
			n = min;
		}
		if (typeof max === 'number' && n > max) {
			n = max;
		}
		return n;
	};

	var wrapImpl = function (method, type) {
		var check = Linko.id;
		switch (type) {
		case 'percentage':
			check = function (x) { return checkNumber(x, 0, 100); };
			break;
		case 'number':
			check = checkNumber;
			break;
		case 'boolean':
			check = function (x) { return !!x; };
			break;
		}
		return function () {
			return check(this._callImpl(method, arguments));
		};
	};

	Player.prototype = {
		'getPlayer': function () {
			return this.activePlayer;
		},

		'closePlayer': function () {
			if (!this.activePlayer) {
				return;
			}

			try {
				this.activePlayer.close();
			}
			catch (e) {
				logger.error('closePlayer: activePlayer.close() threw:', e);
			}

			this.activePlayer = null;
			this._resetGenericCache();
		},

		'_choosePlayer': function (options) {
			options = Linko.util.extend(false, {
				'initOrder': Linko.conf.player.initOrder,
				'blacklist': Linko.conf.player.blacklist,
				'whitelist': Linko.conf.player.whitelist
			}, options);

			var force = !!options.force;
			var chosenPlayer = null;
			var self = this;

			options.initOrder.forEach(function (name, i) {
				if (chosenPlayer) {
					return;
				}

				name = Linko.player.normalizePlayerName(name);

				if (name === null) {
					logger.error('_choosePlayer: invalid player name:', name);
					return;
				}

				if (options.blacklist[name] && !options.whitelist[name]) {
					logger.info('_choosePlayer: ignoring player "' + name + '" because it is blacklisted');
					return;
				}

				var Player = Linko.player[name];

				try {
					chosenPlayer = new Player(force);
				}
				catch (e) {
					logger.info('_choosePlayer: skipping player "' + name + '" because its constructor threw');
				}
			});

			return chosenPlayer;
		},

		'loadPlayer': function (options) {
			var player = this._choosePlayer(options);
			if (player) {
				this._setActivePlayer(player);
			}
			else {
				logger.warn('loadPlayer: no available player');
			}
		},

		'forcePlayer': function (playerName) {
			this.forcedPlayer = playerName;
			if (this.activePlayer && this.activePlayer.name === Linko.player.normalizePlayerName(playerName)) {
				return;
			}
			this.reloadPlayer();
		},

		'_setState': function (newState) {
			try {
				var pos = this.getPosition();
				if (this.state === states.PLAYING &&
				    newState === states.STOPPED &&
				    (!pos || 1 - pos / 100 * this.getDuration() < 1) &&
				    (this.stoppedAt === null || this.stoppedAt + 2000 < (new Date).getTime()))
				{
					newState = states.CLIP_ENDED;
				}
			}
			catch (e) {}

			if (this.state === newState) {
				return;
			}

			this.lastChange.state = (new Date).getTime();
			var oldState = this.state;
			this.state = newState;
			this.es.dispatchEvent('stateChange', [newState, oldState]);
			if (newState === states.CLIP_ENDED) {
				this.es.dispatchEvent('clipEnd');
			}
			if ([states.UNINITIALIZED, states.STOPPED, states.CLIP_ENDED].indexOf(oldState) !== -1 && [states.BUFFERING, states.PLAYING].indexOf(newState) !== -1) {
				this.es.dispatchEvent('clipStart');
			}
		},

		'_resetGenericCache': Linko.noop,

		'_startGenericInterval': function () {
			this._clearGenericInterval();

			var self = this;
			this.genericInterval = (function () {
				var values = {
					'state':       states.UNINITIALIZED,
					'position':    0,
					'volume':      null,
					'bufferStart': 0,
					'bufferEnd':   0,
					'mute':        false
				};

				self._resetGenericCache = function () {
					values.position    = 0;
					values.bufferStart = 0;
					values.bufferEnd   = 0;
				};

				return setInterval(function () {
					if (!Linko.util.get(self, 'activePlayer', 'initialized')) {
						return;
					}

					var newValues = {
						'state':       self.getState(),
						'position':    checkNumber(self.getPosition(), 0, 100),
						'volume':      checkNumber(self.getVolume(), 0, 100),
						'bufferStart': checkNumber(self.getBufferStart(), 0, 100),
						'bufferEnd':   checkNumber(self.getBufferEnd(), 0, 100),
						'mute':        !!self.isMute()
					};

					if (!Linko.util.eq(values, newValues)) {
						self.lastChangeAt = (new Date).getTime();
					}

					['position', 'bufferStart', 'bufferEnd'].forEach(function (property) {
						if (values[property] !== newValues[property]) {
							self.lastChange[property] = (new Date).getTime();
							self.es.dispatchEvent(property + 'Change', [
								newValues[property],
								self.percentsToMilliseconds(newValues[property]),
								values[property],
								self.percentsToMilliseconds(values[property])
							]);
						}
					});

//					logger.debug(JSON.parse(JSON.stringify(newValues)));

					if (newValues.position > values.position && (!self.seekedAt || self.seekedAt + 1000 < (new Date).getTime())) {
						newValues.state = states.PLAYING;
					}
					else if (newValues.position === values.position && (new Date).getTime() - self.lastChange.bufferEnd < 1250) {
						newValues.state = states.BUFFERING;
					}
					else if (newValues.state === states.PLAYING && values.position > 0 && values.position === newValues.position && (new Date).getTime() - self.lastChange.position > 1000) {
						logger.trace('position isn\'t changing, guessing CLIP_ENDED');
						newValues.state = states.CLIP_ENDED;
					}
					else if (newValues.state === states.PLAYING && values.position > 0 && newValues.position === 0 && (!self.seekedAt || self.seekedAt + 1000 < (new Date).getTime())) {
						logger.trace('position got reset to 0, guessing CLIP_ENDED');
						newValues.state = states.CLIP_ENDED;
					}

//					if (newValues.state === states.PLAYING &&
//					    newValues.bufferEnd === values.bufferEnd &&
//					    newValues.position === values.position /*&&
//					    self.getDuration() > 0*/)
//					{
//						newValues.state = states.STOPPED;
//					}

					if (values.volume !== newValues.volume) {
						self.es.dispatchEvent('volumeChange', [newValues.volume, values.volume]);
					}

					if (values.mute !== newValues.mute) {
						self.es.dispatchEvent('mute', [newValues.mute, values.mute]);
					}

					self._setState(newValues.state);

					values = newValues;

					// wantedState

					if (self.wantedState === null || self.wantedState === self.state) {
						return;
					}

					switch (self.wantedState) {
					case states.PLAYING:
						if (newValues.state === states.STOPPED) {
//							self.wantedState = null;
							self.play();
						}
						break;
					case states.PAUSED:
						if (newValues.state === states.PLAYING) {
							self.pause();
						}
						break;
					case states.STOPPED:
						if (newValues.state === states.PLAYING) {
							self.stop();
						}
						break;
					}
				}, 500);
			})();
		},

		'_expectPlay': function (timeout) {
			var player = this.activePlayer;
			if (!timeout) {
				timeout = 15000;
			}
			if (timeout < 0) {
				return;
			}
			var self = this;
			setTimeout(function () {
				if (!self.activePlayer || self.activePlayer !== player) {
					return;
				}
				if (!self.lastChange.position || (new Date).getTime() - self.lastChange.position >= timeout) {
//					if (self.getDuration() === 0) {
//						logger.info('duration is 0, skipping current clip');
//						self.playlist.next(self.shuffle, self.repeat);
//						return;
//					}

					var playerName = self.activePlayer.name;
					logger.warn('_expectPlay: closing player "' + playerName + '" because it seems to be dead');

					var blacklist = Linko.util.copy(Linko.conf.player.blacklist);
					blacklist[playerName] = true;
					self.reloadPlayer({
						'blacklist': blacklist
					});
				}
			}, timeout);
		},

		'millisecondsToPercents': function (ms) {
			var duration = this.getDuration();
			return duration && ms ? ms / duration * 100 : 0;
		},

		'percentsToMilliseconds': function (p) {
			var duration = this.getDuration();
			return duration && p ? duration * p / 100 : 0;
		},

		'_clearGenericInterval': function () {
			if (this.genericInterval !== null) {
				clearInterval(this.genericInterval);
				this._resetGenericCache();
				this._resetGenericCache = Linko.noop;
			}
		},

		'_setActivePlayer': function (player, initWith) {
//			if (this.activePlayer && this.activePlayer.name === player.name) {
//				return;
//			}

			this.closePlayer();
			if (!player) {
				logger.warn('_setActivePlayer: no player given');
				return;
			}
			logger.info('_setActivePlayer: using player "' + player.name + '"');

			var self = this;
			this.activePlayer = player;
			var es = this.activePlayer.es;
			es.addEventListener('*', function (event, params) {
				logger.debug('impl event "' + event + '"', params);
			});

			es.addEventListener('ready', function () {
				if (self.activePlayer !== player) {
					return;
				}
				self.setVolume(self.volume);
				self.setMute(self.mute);

				self._startGenericInterval();
				self.methodQueue.forEach(function (item) {
					self._callImpl(item[0], item[1]);
				});
				self.methodQueue = [];
				if (self.tryingToPlay) {
					self.play();
				}
			});

			es.addEventListener('error', function (fatal) {
				if (self.activePlayer !== player) {
					return;
				}
				//if (fatal) {
				//	logger.warn('blacklisting player "' + player.name + '" because it signaled a fatal error');
				//	Linko.conf.player.blacklist[player.name] = true;
				//}

				var blacklist = Linko.util.copy(Linko.conf.player.blacklist);
				blacklist[player.name] = true;
				self.reloadPlayer({
					'blacklist': blacklist
				});
			});

			this.activePlayer.init(initWith || {
				'container': this.container,
				'width':     this.width,
				'height':    this.height,
				'clip':      this.playlist.activeTrack()
			});

			this.lastChangeAt = (new Date).getTime();
		},

		'setPlaylist': function (playlist) {
			if (this.playlist) {
				this.playlist.es.removeEventListener(this._plListenerId);
			}
			this.playlist = playlist;

			var self = this;
			var plES = this.playlist.es;
			this._plListenerId = plES.addEventListener('activeTrackChange', function () {
				if (self.playlist !== playlist) {
					return;
				}
				self.stop();
				if (self.playlist.tracks.length > 0) {
					setTimeout(function () {
						self.play();
					}, 100);
				}
			});
		},

		/* Player interface */

		'_callImpl': function (method, params) {
			if (!params) {
				params = [];
			}

			if (!this.activePlayer) {
				logger.warn('_callImpl: no active player', [method, params]);
				return null;
			}
			else if (typeof this.activePlayer[method] !== 'function') {
				logger.warn('_callImpl: player ' + this.activePlayer.name + ' doesn\'t support method "' + method + '"');
				return null;
			}
			else if (!this.activePlayer.initialized) {
//				logger.warn('_callImpl:player not initialized yet');
				this.methodQueue.push([method, params]);
				return null;
			}
			else {
				try {
					return this.activePlayer[method].apply(this.activePlayer, params);
				}
				catch (e) {
					logger.debug('_callImpl: player (' + this.activePlayer.name + ') threw, method = "' + method + '"');
					return null;
				}
			}
		},

		'getState': function () {
			return this.wantedState !== null ? this.wantedState : this.state;
		},

		'getSize': function () {
			return {
				'width':  this.width,
				'height': this.height
			};
		},

		'getRepeat': function () {
			return this.repeat;
		},
		'setRepeat': function (val) {
			val = !!val;
			if (val === this.repeat) {
				return;
			}
			this.repeat = val;
			this.es.dispatchEvent('repeatChange', [this.repeat]);
			if (this.repeat) {
				this.setShuffle(false);
			}
			var self = this;
			setTimeout(function () {
				var store = self._getStore();
				if (!store) {
					return;
				}
				store.set('repeat', String(+!!val));
			}, 0);
		},
		'toggleRepeat': function () {
			this.setRepeat(!this.repeat);
		},
		'getShuffle': function () {
			return this.shuffle;
		},
		'setShuffle': function (val) {
			val = !!val;
			if (val === this.shuffle) {
				return;
			}
			this.shuffle = val;
			this.es.dispatchEvent('shuffleChange', [this.shuffle]);
			if (this.shuffle) {
				this.setRepeat(false);
			}
			var self = this;
			setTimeout(function () {
				var store = self._getStore();
				if (!store) {
					return;
				}
				store.set('shuffle', String(+!!val));
			}, 0);
		},
		'toggleShuffle': function () {
			this.setShuffle(!this.shuffle);
		},

		'next': function () {
			this.playlist.next(this.shuffle, this.repeat);
		},
		'prev': function () {
			this.playlist.prev(this.shuffle, this.repeat);
		},

		'reloadPlayer': function (options) {
			if (!options && this.forcedPlayer) {
				options = {
					'initOrder': [this.forcedPlayer],
					'blacklist': {}
				};
			}

			var autoPlay = this.state === states.BUFFERING || this.state === states.PLAYING || this.wantedState === states.PLAYING;
			var position = this.getPosition();

			this.closePlayer();
			var player = this._choosePlayer(options);
			if (!player) {
				logger.warn('reloadPlayer: no players available');
				this.es.dispatchEvent('noPlayer');
				return;
			}

			var clip = this.playlist.activeTrack();
			this._setActivePlayer(player, {
				'container':  this.container,
				'width':      this.width,
				'height':     this.height,
				'clip':       clip,
				'noAutoPlay': !!autoPlay,
				'position':   position
			});
		},

		'setSize': function (size) {
			if (!size) {
				return;
			}

			var changed = false;

			if (typeof size.width === 'number' && this.width !== size.width) {
				this.width = size.width;
				changed = true;
			}

			if (typeof size.height === 'number' && this.height !== size.height) {
				this.height = size.height;
				changed = true;
			}

			if (changed) {
				this.reloadPlayer();
			}
		},

		'getDuration': function () {
			return 1000 * Math.max(0, checkNumber(this._callImpl('getDuration')));
		},

		'getPosition': wrapImpl('getPosition', 'percentage'),
		'setPosition': function () {
			this.seekedAt = (new Date).getTime();
			this._callImpl('setPosition', arguments);
		},

		'_getStore': function () {
			if (!this._store) {
				this._store = Linko.store.getStore({
					'type': 'db',
					'name': 'linko-player-settings',
					'db':   'site'
				});
			}
			return this._store;
		},

		'loadConfig': function () {
			var store = this._getStore();
			if (!store) {
				return false;
			}
			var volume  = +store.get('volume', '30');
			var repeat  = !!+store.get('repeat', '1');
			var shuffle = !!+store.get('shuffle', '1');
			logger.info('loadConfig: volume =', volume, '| shuffle =', shuffle, '| repeat =', repeat);
			this.setVolume(volume);
			this.setRepeat(repeat);
			this.setShuffle(shuffle);
			return true;
		},

		'getVolume': function () {
			return this.volume;
		},
		'setVolume': function (volume) {
			volume = checkNumber(volume);
			this.volume = volume;
			this._callImpl('setVolume', [volume]);
			var self = this;
			setTimeout(function () {
				var store = self._getStore();
				if (!store) {
					return;
				}
				store.set('volume', String(volume));
			}, 0);
		},

		'getBufferStart': wrapImpl('getBufferStart', 'percentage'),
		'getBufferEnd':   wrapImpl('getBufferEnd',   'percentage'),

		'getFullscreen': wrapImpl('getFullscreen', 'boolean'),
		'setFullscreen': wrapImpl('setFullscreen'),

		'_skipped': 0,
		'play': function (/* [clip] */) {
			if (arguments.length > 0) {
				this.setPlaylist(new Linko.Playlist({
					'tracks': Linko.util.array(arguments)
				}));
			}

			var clip = this.playlist.activeTrack();
			if (!clip) {
				logger.warn('play: playlist is empty');
				return;
			}
			if (!clip.url && !clip.youtube) {
				if (!this.playlist.getTrackURL(clip)) {
					logger.debug('play: current clip has no url');
					if (++this._skipped >= this.playlist.tracks.length) {
						logger.warn('play: playlist doesn\'t contain any playable tracks');
						return;
					}
					var self = this;
					setTimeout(function () {
						self.playlist.next(false, self.repeat);
					}, 0);
					return;
				}
			}
			this._skipped = 0;

//			if (this.wantedState === states.PLAYING && Linko.util.eq(clip, this.clip)) {
//				return;
//			}

			this._resetGenericCache();
			this.wantedState = this.wantedState === states.PLAYING ? null : states.PLAYING;

			if (clip.youtube) {
				this.forcePlayer('yt');
			}
			else if (!this.activePlayer || this.activePlayer.name === 'YouTube') {
				var blacklist = Linko.util.extend({}, Linko.conf.player.blacklist, { 'YouTube':true });
				this.reloadPlayer({
					'initOrder': Linko.conf.player.initOrder,
					'blacklist': blacklist,
					'whitelist': Linko.conf.player.whitelist
				});
			}

			this._callImpl('play', [clip]);
			this._expectPlay();
		},

		'stop': function () {
			this.wantedState = states.STOPPED;
			this.stoppedAt = (new Date).getTime();
			this._callImpl('stop');
		},

		'pause': function () {
			this.wantedState = states.PAUSED;
			this._callImpl('pause');
		},

		'resume': function () {
			this.wantedState = states.PLAYING;
			this._callImpl('resume');
		},

		'isPaused': wrapImpl('isPaused', 'boolean'),

		'togglePause': function () {
			var state = this.getState();
			if (state === states.PLAYING) {
				this.pause();
				return false;
			}
			else if (state === states.PAUSED) {
				this.resume();
				return true;
			}
			else {
				this.play();
				return true;
			}
		},

		'setMute': function (mute) {
			this.mute = mute;
			this._callImpl(mute ? 'mute' : 'unmute', []);
			return mute;
		},

		'mute':   function () { this.setMute(true); },
		'unmute': function () { this.setMute(false); },
		'isMute': function () { return this.mute; },

		'toggleMute': function () {
			return this.setMute(!this.mute);
		},

		'close': function () {
			this.stop();
			this.closePlayer();
			this.playlist = new Linko.Playlist();
		}
	};

	return Player;
})();

// Linko.pl - end of file 'player/Player.js'
// Linko.pl - start of file 'player/QuickTime.js'

Linko.player.QuickTime = (function () {
	var states = Linko.player.states;
	var instanceCount = 0;

	var logger = Linko.log.getLogger('Linko.player.QuickTime');

	var QuickTime = function (force) {
		if (!force && !Linko.util.detectPlugin('QuickTime', 'QuickTime.QuickTime')) {
			throw 'no qt';
		}

		this.impl        = null;
		this.initialized = false;
		this.state       = states.UNINITIALIZED;
		this._clipURL    = null;

		this.es = new Linko.EventSource(['ready', 'error']);
	};

	QuickTime.logger = logger;

	QuickTime.prototype = {
		'name': 'QuickTime',

		'_willFail': function (clip) {
			return /\.(?:wma|wmv|flv)$/.test(Linko.util.get(clip, 'url'));
		},

		'init': function (options) {
			if (this.initialized) {
				return;
			}

			if (this._willFail(options && options.clip)) {
				return void this.es.dispatchEvent('error');
			}

			this.instanceNumber = ++instanceCount;

			this.container    = options.container || null;
			this.width        = options.width || 0;
			this.height       = options.height || 0;
			this.videoEnabled = !!(this.width && this.height);

			if (!this.container) {
				this.ownContainer = true;
				this.container = document.createElement('div');
				this.container.setAttribute('id', 'quicktime-container-' + this.instanceNumber);
				document.body.appendChild(this.container);
			}

			if (this.ownContainer && !this.videoEnabled) {
				Linko.util.hideElement(this.container);
			}

			var clipURL = Linko.util.get(options, 'clip', 'url');
			this._clipURL = clipURL;
			var autoPlay = !!clipURL && !options.noAutoPlay;

			var encodedURL = Linko.util.htmlEncode(clipURL || 'dliufgvhaeoilurghaserogiluh');
			this.container.innerHTML =
				'<object id="quicktime-object-' + this.instanceNumber + '"' +
				'        class="linko-player-object"' +
				'        classid="clsid:02BF25D5-8C17-4B23-BC80-D3488ABDDC6B"' +
				'        width="' + this.width + '"' +
				'        height="' + this.height + '"' +
				'        codebase="http://www.apple.com/qtactivex/qtplugin.cab">' +
				'  <param name="src" value="' + encodedURL + '">' +
				'  <param name="volume" value="30">' +
				'  <param name="controller" value="false">' +
				'  <param name="width" value="' + this.width + '">' +
				'  <param name="height" value="' + this.height + '">' +
				'  <embed id="quicktime-embed-' + this.instanceNumber + '"' +
				'         class="linko-player-embed"' +
				'         width="' + this.width + '"' +
				'         height="' + this.height + '"' +
				'         src="' + encodedURL + '"' +
				'         autoplay="' + autoPlay + '"' +
				'         volume="30"' +
				'         controller="false"' +
				'         pluginspage="http://www.apple.com/quicktime/download/">' +
				'  </embed>' +
				'</object>'
			;

			this.impl = $(Linko.util.sprintf('#quicktime-%s-%d', Linko.system.isIE ? 'object' : 'embed', this.instanceNumber))[0];

			var self = this;
			setTimeout(function () {
				if (self.initialized) {
					return;
				}
				self.initialized = true;
				self.es.dispatchEvent('ready');
				if (options.position) {
					setTimeout(function () {
						self.setPosition(options.position);
					}, 500);
				}
			}, 3000);
		},

		'play': function (clip) {
			if (typeof clip === 'string') {
				clip = { 'url':clip };
			}
			if (this._willFail(clip)) {
				self.es.dispatchEvent('error');
				return;
			}

			var reset = true;
			try {
				if (clip.url === this._clipURL) {
					reset = false;
				}
			}
			catch (e) {}

			if (reset) {
				this.close();
				var self = this;
				setTimeout(function () {
					self.init({
						'clip':      clip,
						'container': self.container,
						'width':     self.width,
						'height':    self.height
					});
				}, 100);
				return;
			}

			this.state = states.PLAYING;
//			this.impl.SetAutoPlay(true);
//			this.impl.SetURL(url);
			this.impl.Play();
		},

		'stop': function () {
			this.state = states.STOPPED;
			this.impl.Stop();
		},

		'pause': function () {
			this.state = states.PAUSED;
			this.impl.SetRate(0);
		},

		'resume': function () {
			this.state = states.PLAYING;
			this.impl.SetRate(1);
		},

		'isPaused': function () {
			return this.impl.GetRate() === 0;
		},

		'getState': function () {
			if (!this.initialized || !this.state) {
				return states.UNINITIALIZED;
			}

			return this.state;
		},

		// only for debugging
		'getImplState': function () {
			return this.impl.GetPluginStatus();
		},

		'getDuration': function () {
			try {
				var timeScale = this.impl.GetTimeScale();
				return !timeScale ? 0 : this.impl.GetDuration() / timeScale;
			}
			catch (e) {
				return 0;
			}
		},

		'getPosition': function () {
			var duration = this.impl.GetDuration();
			return !duration ? 0 : this.impl.GetTime() / duration * 100;
		},

		'setPosition': function (percent) {
			this.impl.SetTime(percent / 100 * this.impl.GetDuration());
		},

		'getVolume': function () {
			return this.impl.GetVolume();
		},

		'setVolume': function (vol) {
			this.impl.SetVolume(vol);
		},

		'getBufferStart': function () {
			return 0;
		},

		'getBufferEnd': function () {
			var duration = this.impl.GetDuration();
			return !duration ? 0 : this.impl.GetMaxTimeLoaded() / duration * 100;
		},

		'getFullscreen': function () {
			return false;
		},

		'setFullscreen': function (bool) {
		},

		'mute': function () {
			this.impl.SetMute(true);
		},

		'unmute': function () {
			this.impl.SetMute(false);
		},

		'isMute': function () {
			return !!this.impl.GetMute();
		},

		'close': function () {
			this.initialized = false;
			this.state       = states.UNINITIALIZED;
			this.impl        = null;
			$(this.container)[this.ownContainer ? 'remove' : 'empty']();
		}
	};

	return QuickTime;
})();

// Linko.pl - end of file 'player/QuickTime.js'
// Linko.pl - start of file 'player/VLC.js'

Linko.player.VLC = (function () {
	var states = Linko.player.states;
	var instanceCount = 0;

	var logger = Linko.log.getLogger('Linko.player.VLC');

	var VLC = function (force) {
		if (!force && !Linko.util.detectPlugin('VLC MultiMedia', 'VideoLAN.VLCPlugin')) {
			throw 'no vlc';
		}

		this.impl        = null;
		this.initialized = false;
		this.state       = states.UNINITIALIZED;

		this.es = new Linko.EventSource(['ready', 'error']);
	};

	VLC.logger = logger;

	VLC.prototype = {
		'name': 'VLC',

		'_willFail': function (clip) {
			return false;
		},

		'init': function (options) {
			if (this.initialized) {
				return;
			}

			if (this._willFail(Linko.util.get(options, 'clip'))) {
				return void this.es.dispatchEvent('error');
			}

			this.closed = false;

			this.instanceNumber = ++instanceCount;

			this.container      = options.container || null;
			this.width          = options.width || 0;
			this.height         = options.height || 0;
			this.videoEnabled   = this.width && this.height;

			if (!this.container) {
				this.ownContainer = true;
				this.container = document.createElement('div');
				this.container.setAttribute('id', 'vlc-container-' + this.instanceNumber);
				document.body.appendChild(this.container);
			}

			if (this.ownContainer && !this.videoEnabled) {
				Linko.util.hideElement(this.container);
			}

			this.container.innerHTML =
				'<object id="vlc-object-' + this.instanceNumber + '"' +
				'        class="linko-player-object"' +
				'        classid="clsid:9BE31822-FDAD-461B-AD51-BE1D1C159921"' +
				'        codebase="http://downloads.videolan.org/pub/videolan/vlc/latest/win32/axvlc.cab"' +
				'        width="' + this.width + '"' +
				'        height="' + this.height + '">' +
				'  <param name="AutoPlay" value="True">' +
				'  <param name="AutoLoop" value="False">' +
				'  <param name="ShowDisplay" value="' + (this.videoEnabled ? 'True' : 'False') + '">' +
				'  <embed id="vlc-embed-' + this.instanceNumber + '"' +
				'         class="linko-player-embed"' +
				'         type="application/x-vlc-plugin"' +
				'         pluginspage="http://www.videolan.org"' +
				'         version="VideoLAN.VLCPlugin.2"' +
				'         loop="no"' +
				'         width="' + this.width + '"' +
				'         height="' + this.height + '">' +
				'  </embed>' +
				'</object>'
			;

			this.impl = $(Linko.util.sprintf('#vlc-%s-%d', Linko.system.isIE ? 'object' : 'embed', this.instanceNumber))[0];

			var self  = this;
			var alive = false;

			setTimeout(function () {
				clearInterval(wait);
				if (!alive && !self.closed) {
					logger.error('Not alive after 5000ms, dispatching an error event');
					self.es.dispatchEvent('error', [true]);
				}
			}, 5000);

			var ready = Linko.util.once(function () {
				alive = true;
				self.initialized = true;
				self.es.dispatchEvent('ready');
				if (options.noAutoPlay) {
					self.stop();
				}
				else if (options.clip && !options.noAutoPlay) {
					self.play(options.clip);
				}

				if (options.position) {
					setTimeout(function () {
						self.setPosition(options.position);
					}, 500);
				}
			});

			if (Linko.system.isIE) {
				/*
				setTimeout(function () {
					self.impl.attachEvent('MediaPlayerEncounteredError', function () {
						self.es.dispatchEvent('error', [false]);
					});
					self.impl.attachEvent('MediaPlayerTimeChanged', ready);
					self.impl.attachEvent('MediaPlayerPositionChanged', ready);
					self.impl.attachEvent('MediaPlayerEndReached', ready);

					self.impl.attachEvent('MediaPlayerPlaying', ready);
					self.impl.attachEvent('MediaPlayerPaused', ready);
				}, 0);
				*/
				setTimeout(ready, 2000);
				return;
			}

			var wait = setInterval(function () {
				if (self.closed) {
					clearInterval(wait);
					return;
				}

				try {
					if (typeof Linko.util.get(self, 'impl', 'input', 'state') !== 'number') {
						return;
					}
				}
				catch (e) {
					return;
				}

				clearInterval(wait);
				ready();
			}, 500);
		},

		'play': function (clip) {
			if (typeof clip === 'string' || !clip) {
				clip = { 'url':clip };
			}
			if (this._willFail(clip)) {
				self.es.dispatchEvent('error');
				return;
			}

			var self = this;
			window.setTimeout(function () {
				try {
					if (clip.url) {
						self.impl.playlist.items.clear();
						var index = self.impl.playlist.add(clip.url);
						self.impl.playlist.playItem(index);
					}
					else {
						self.impl.playlist.play();
					}
				}
				catch (e) {}
			}, 200);

			this.state = states.BUFFERING;
		},

		'stop': function () {
			this.state = states.STOPPED;
			try {
				this.impl.playlist.stop();
			}
			catch (e) {}
		},

		'pause': function () {
			if (this.getState() === states.PAUSED) {
				return;
			}
			this.state = states.PAUSED;
			try {
				this.impl.playlist.togglePause();
			}
			catch (e) {}
		},

		'resume': function () {
			this.state = states.PLAYING;
			try {
				this.impl.playlist.play();
			}
			catch (e) {}
		},

		'isPaused': function () {
			return this.state === states.PAUSED;
		},

		'getState': function () {
			if (!this.initialized) {
				return states.UNINITIALIZED;
			}

			try {
				var implState = Number(this.impl.input.state);
			}
			catch (e) {
				logger.warn('getState: accessing impl.input.state threw:', e);
				return states.UNINITIALIZED;
			}

			switch (implState) {
			case 0: // IDLE
			case 1: // OPENING
			case 5: // STOPPING
				return states.STOPPED;

			case 2: // BUFFERING
				return states.BUFFERING;

			case 3: // PLAYING
				return states.PLAYING;

			case 4: // PAUSED
				return states.PAUSED;

			case 6: // ENDED
				return states.CLIP_ENDED;

			default:
				return states.STOPPED;
			}
		},

		'getDuration': function () {
			try {
				return Math.max(0, this.impl.input.length / 1000);
			}
			catch (e) {
				return 0;
			}
		},

		'getPosition': function () {
			try {
				var len = this.impl.input.length;
				return !len ? 0 : this.impl.input.time / len * 100;
			}
			catch (e) {
				return 0;
			}
		},

		'setPosition': function (percent) {
			try {
				this.impl.input.time = this.getDuration() * percent * 10;
			}
			catch (e) {}
		},

		'getVolume': function () {
			try {
				return this.impl.audio.volume / 2;
			}
			catch (e) {}
		},

		'setVolume': function (vol) {
			try {
				this.impl.audio.volume = vol * 2;
			}
			catch (e) {}
		},

		'getBufferStart': function () {
			return 0;
		},

		'getBufferEnd': function () {
			// TODO: Does the API have a method for this?
			return 100;
		},

		'getFullscreen': function () {
			try {
				return !!this.impl.video.fullscreen;
			}
			catch (e) {
				return false;
			}
		},

		'setFullscreen': function (bool) {
			try {
				this.impl.video.fullscreen = !!bool;
			}
			catch (e) {}
		},

		'mute': function () {
			try {
				this.impl.audio.mute = true;
			}
			catch (e) {}
		},

		'unmute': function () {
			try {
				this.impl.audio.mute = false;
			}
			catch (e) {}
		},

		'isMute': function () {
			try {
				return !!this.impl.audio.mute;
			}
			catch (e) {
				return false;
			}
		},

		'close': function () {
			this.closed      = true;
			this.initialized = false;
			this.stat        = states.UNINITIALIZED;
			this.stop();
			this.impl = null;
			$(this.container)[this.ownContainer ? 'remove' : 'empty']();
		}
	};

	return VLC;
})();

// Linko.pl - end of file 'player/VLC.js'
// Linko.pl - start of file 'player/WindowsMediaPlayer.js'

Linko.player.WindowsMediaPlayer = (function () {
	var states = Linko.player.states;
	var instanceCount = 0;

	var logger = Linko.log.getLogger('Linko.player.WindowsMediaPlayer');

	var WindowsMediaPlayer = function (force) {
		if (!force && !Linko.util.detectPlugin('Windows Media Player', 'MediaPlayer.MediaPlayer.1')) {
			throw 'no wmp';
		}

		this.impl        = null;
		this.initialized = false;
		this.state       = states.UNINITIALIZED;

		this.es = new Linko.EventSource(['ready', 'error']);
	};

	WindowsMediaPlayer.logger = logger;

	WindowsMediaPlayer.prototype = {
		'name': 'WindowsMediaPlayer',

		'_willFail': function (clip) {
			return /\.(?:mov|flv)$/.test(Linko.util.get(clip, 'url'));
		},

		'init': function (options) {
			if (this.initialized) {
				return;
			}

			if (this._willFail(Linko.util.get(options, 'clip'))) {
				return void this.es.dispatchEvent('error');
			}

			this.closed = false;

			this.instanceNumber = ++instanceCount;

			this.container    = options.container || null;
			this.width        = options.width || 0;
			this.height       = options.height || 0;
			this.videoEnabled = !!(this.width && this.height);

			if (!this.container) {
				this.ownContainer = true;
				this.container = document.createElement('div');
				this.container.setAttribute('id', 'wmp-container-' + this.instanceNumber);
				document.body.appendChild(this.container);
			}

			if (this.ownContainer && !this.videoEnabled) {
				Linko.util.hideElement(this.container);
			}

			var uiMode  = this.videoEnabled ? 'none' : 'invisible';
			var enabled = this.videoEnabled ? 'true' : 'false';
			this.container.innerHTML =
				'<object id="wmp-object-' + this.instanceNumber + '"' +
				'        class="linko-player-object"' +
				'        classid="clsid:6BF52A52-394A-11D3-B153-00C04F79FAA6"' +
				'        width="' + this.width + '"' +
				'        height="' + this.height + '"' +
				'        type="application/x-ms-wmp">' +
				'  <param name="uiMode" value="' + uiMode + '">' +
				'  <param name="enabled" value="' + enabled + '">' +
				'  <param name="autoStart" value="true">' +
				'  <param name="volume" value="30">' +
				'  <embed id="wmp-embed-' + this.instanceNumber + '"' +
				'         class="linko-player-embed"' +
				'         type="application/x-ms-wmp"' +
				'         width="' + this.width + '"' +
				'         height="' + this.height + '"' +
				'         uiMode="' + uiMode + '"' +
				'         enabled="' + enabled + '"' +
				'         autostart="true"' +
				'         volume="30">' +
				'  </embed>' +
				'</object>'
			;

			var self  = this;
			var alive = false;

			self.impl = $(Linko.util.sprintf('#wmp-%s-%d', Linko.system.isIE ? 'object' : 'embed', self.instanceNumber))[0];

			var wait = null;
			setTimeout(function () {
				if (wait !== null) {
					clearInterval(wait);
				}
				if (!alive && !self.closed) {
					logger.error('Not alive after 5000ms, dispatching an error event');
					self.es.dispatchEvent('error', [true]);
					self.close();
				}
			}, 5000);

			var ready = Linko.util.once(function () {
				alive = true;
				self.initialized = true;
				self.es.dispatchEvent('ready');
				if (options.noAutoPlay) {
					self.stop();
				}
				if (options.clip && !options.noAutoPlay) {
					self.play(options.clip);
				}
				if (options.position) {
					setTimeout(function () {
						self.setPosition(options.position);
					}, 500);
				}
			});

			if (Linko.system.isIE) {
				/*
				setTimeout(function () {
					self.impl.attachEvent('PlayStateChange', function (state) {
						Linko.logger.info('WMP state: ' + state);
						ready();
					});
					self.impl.attachEvent('Error', function (ertsu) {
						Linko.logger.info('WMP ertsu: ' + JSON.stringify(ertsu));
						self.es.dispatchEvent('error');
					});
					self.impl.attachEvent('MediaError', function (ertsu) {
						Linko.logger.info('WMP media ertsu: ' + JSON.stringify(ertsu));
					});
				}, 0);
				*/
				setTimeout(ready, 3000);
				return;
			}

			var wait = setInterval(function () {
				if (self.closed) {
					clearInterval(wait);
					return;
				}

				try {
					if (typeof Linko.util.get(self, 'impl', 'playState') !== 'number') {
						return;
					}
				}
				catch (e) {
					return;
				}

				clearInterval(wait);

				ready();
			}, 500);

		},

		'play': function (clip) {
			if (typeof clip === 'string') {
				clip = { 'url':clip };
			}
			if (this._willFail(clip)) {
				self.es.dispatchEvent('error');
				return;
			}

			this.state = states.PLAYING;
			this.impl.network.bufferingTime = 60000;
			this.impl.URL = clip.url;

			var tries = 10;
			var self = this;
			var id = setInterval(function () {
				try {
					if (self.impl.playState === 3) {
						clearInterval(id);
						return;
					}

					if (--tries === 0 || self.impl.playState === 10) {
						self.impl.controls.play();
						clearInterval(id);
						return;
					}
				}
				catch (e) {
					clearInterval(id);
				}
			}, 200);
		},

		'stop': function () {
			this.state = states.STOPPED;
			this.impl.controls.stop();
		},

		'pause': function () {
			this.state = states.PAUSED;
			this.impl.controls.pause();
		},

		'resume': function () {
			this.state = states.PLAYING;
			this.impl.controls.play();
		},

		'isPaused': function () {
			return this.state === states.PAUSED;
		},

		'getState': function () {
			if (!this.initialized) {
				return states.UNINITIALIZED;
			}

			try {
				var implState = Number(this.impl.playState);
			}
			catch (e) {
				logger.warn('getState: accessing impl.playState threw:', e);
				return states.UNINITIALIZED;
			}

			switch (implState) {
			case  0: // Undefined
				return states.UNINITIALIZED

			case  1: // Stopped
				return states.STOPPED;

			case  2: // Paused
				return states.PAUSED;

			case  3: // Playing
			case  4: // ScanForward
			case  5: // ScanReverse
			case  9: // Transitioning
			case 10: // Ready
				return states.PLAYING;

			case  6: // Buffering
			case  7: // Waiting
				return states.BUFFERING;

			case  8: // MediaEnded
				return states.CLIP_ENDED;

			case 11: // Reconnecting
			default:
				return states.STOPPED;
			}
		},

		'getDuration': function () {
			try {
				return this.impl.currentMedia.duration;
			}
			catch (e) {
				return 0;
			}
		},

		'getPosition': function () {
			var duration = this.getDuration();
			return !duration ? 0 : this.impl.controls.currentPosition / duration * 100;
		},

		'setPosition': function (percent) {
			this.impl.controls.currentPosition = percent / 100 * this.getDuration();
		},

		'getVolume': function () {
			return this.impl.settings.volume;
		},

		'setVolume': function (vol) {
			this.impl.settings.volume = vol;
		},

		'getBufferStart': function () {
			return 0;
		},

		'getBufferEnd': function () {
			return this.impl.network.downloadProgress;
		},

		'getFullscreen': function () {
			return !!this.impl.fullScreen;
		},

		'setFullscreen': function (bool) {
			this.impl.fullScreen = !!bool;
		},

		'mute': function () {
			this.impl.settings.mute = true;
		},

		'unmute': function () {
			this.impl.settings.mute = false;
		},

		'isMute': function () {
			return !!this.impl.settings.mute;
		},

		'close': function () {
			this.closed      = true;
			this.initialized = false;
			this.state       = states.UNINITIALIZED;
			try {
				this.impl.close();
			}
			catch (e) {}
			this.impl = null;
			$(this.container)[this.ownContainer ? 'remove' : 'empty']();
		}
	};

	return WindowsMediaPlayer;
})();

// Linko.pl - end of file 'player/WindowsMediaPlayer.js'
// Linko.pl - start of file 'player/YouTube.js'

Linko.player.YouTube = (function () {
	var logger = Linko.log.getLogger('Linko.player.YouTube');

	var initES = new Linko.EventSource(['init']);
	window.onYouTubePlayerReady = function (id) {
		logger.info('window.onYouTubePlayerReady: id = ' + id);
		initES.dispatchEvent('init', [id]);
	};

	var states = Linko.player.states;
	var instanceCount = 0;

	var YouTube = function (force) {
		/*TODO; Flash detection*/
		//if (!force && !Linko.util.detectPlugin('YouTube', 'YouTube.YouTube')) {
		//	throw 'no flash';
		//}

		this.impl        = null;
		this.initialized = false;
		this.state       = states.UNINITIALIZED;

		this.es = new Linko.EventSource(['ready', 'error']);
	};

	YouTube.logger = logger;

	var f2s = function (f) {
		var s = ['f', (new Date).getTime(), String(Math.random()).slice(2)].join('_');
		window[s] = function () {
			return f.apply(null, arguments);
		};
		return s;
	};

	YouTube.prototype = {
		'name': 'YouTube',

		'_willFail': function (clip) {
			return !Linko.util.get(clip, 'youtube');
		},

		'init': function (options) {
			if (this.initialized) {
				return;
			}

			if (this._willFail(Linko.util.get(options, 'clip'))) {
				return void this.es.dispatchEvent('error');
			}

			this.instanceNumber = ++instanceCount;

			this.container    = options.container || null;
			this.width        = options.width || 0;
			this.height       = options.height || 0;
			this.videoEnabled = !!(this.width && this.height);
			this.clip         = options.clip || null;

			if (!this.container) {
				this.ownContainer = true;
				this.container = document.createElement('div');
				this.container.setAttribute('id', 'youtube-container-' + this.instanceNumber);
				document.body.appendChild(this.container);
			}

			if (this.ownContainer && !this.videoEnabled) {
				Linko.util.hideElement(this.container);
			}

			var objId = 'youtube-object-' + this.instanceNumber;

			var playerURL = 'http://www.youtube.com/apiplayer?enablejsapi=1&version=3&playerapiid=' + objId;

			var self = this;

			var placeHolder = document.createElement('div');
			placeHolder.setAttribute('id', objId);
			this.container.appendChild(placeHolder);

			var alive = false;

			var listener = initES.addEventListener('init', function (id) {
				if (objId !== id) {
					return;
				}

				initES.removeEventListener(listener);

				alive = true;
				self.impl = document.getElementById(objId);
				self.initialized = true;
				try {
					self.impl.addEventListener('onStateChange', f2s(function () {
						logger.debug('onStateChange:', arguments);
					}));
					self.impl.addEventListener('onError', f2s(function () {
						logger.warn('onError:', arguments);
						self.dispatchEvent('error');
					}));
				}
				catch (e) {
					logger.warn('init: impl.addEventListener failed with exception:', e);
				}
				self.es.dispatchEvent('ready');
				if (options.noAutoPlay) {
					self.stop();
				}
				else if (options.clip && !options.noAutoPlay) {
					self.play(options.clip);
				}

				if (options.position) {
					setTimeout(function () {
						logger.debug('Calling setPosition(' + options.position + ')');
						self.setPosition(options.position);
					}, 500);
				}
			});

			var params = { 'allowScriptAccess':'always', 'allowFullscreen':'true' };
			var atts = { 'id':objId };

			setTimeout(function () {
	   			swfobject.embedSWF(playerURL, objId, String(self.width), String(self.height), '8', null, null, params, atts);
			}, 100);

			setTimeout(function () {
				if (!alive && !self.closed) {
					logger.error('Not alive after 30s, dispatching an error event');
					self.es.dispatchEvent('error', [true]);
				}
			}, 30000);
		},

		'play': function (clip) {
			if (typeof clip === 'string') {
				clip = { 'youtube':clip };
			}

			if (this._willFail(clip)) {
				self.es.dispatchEvent('error');
			}

			this.state = states.PLAYING;
			this.impl.loadVideoById(clip.youtube);
			this.impl.playVideo();
		},

		'stop': function () {
			this.state = states.STOPPED;
			this.impl.stopVideo();
		},

		'pause': function () {
			this.state = states.PAUSED;
			this.impl.pauseVideo();
		},

		'resume': function () {
			this.state = states.PLAYING;
			this.impl.playVideo();
		},

		'isPaused': function () {
			return this.impl.getPlayerState() === 2;
		},

		'getState': function () {
			if (!this.initialized) {
				return states.UNINITIALIZED;
			}

			try {
				var state = this.impl.getPlayerState();
			}
			catch (e) {
				logger.warn('getState: this.impl.getPlayerState() failed with exception:', e);
				return states.UNINITIALIZED;
			}

			switch (Number(state)) {
			// Unstarted
			case -1:
				return states.STOPPED;

			// Ended
			case 0:
				return states.CLIP_ENDED;

			// Playing
			case 1:
				return states.PLAYING;

			// Paused
			case 2:
				return states.PAUSED;

			// Buffering
			case 3:
				return states.BUFFERING;

			// Video cued
			case 5:
				return states.STOPPED;

			default:
				return states.UNINITIALIZED;
			}
		},

		'getImplState': function () {
			return this.impl.getPlayerState();
		},

		// try-catch because this one is used internally, too
		'getDuration': function () {
			try {
				return this.impl.getDuration() || 0;
			}
			catch (e) {
				logger.warn('getDuration: this.impl.getDuration() failed with exception:', e);
				return 0;
			}
		},

		'getPosition': function () {
			var duration = this.getDuration();
			return !duration ? 0 : this.impl.getCurrentTime() / duration * 100 || 0;
		},

		'setPosition': function (percent) {
			this.impl.seekTo(percent / 100 * this.getDuration(), true);
		},

		'getVolume': function () {
			return this.impl.getVolume();
		},

		'setVolume': function (vol) {
			this.impl.setVolume(vol);
		},

		// TODO: The buffer values we get from impl are a bit off. Investigate.

		'getBufferStart': function () {
			var cbTotal = this.impl.getVideoBytesTotal();
			var cbStart = this.impl.getVideoStartBytes();
			return cbStart / (cbStart + cbTotal) * 100 || 0;
		},

		'getBufferEnd': function () {
			var cbTotal = this.impl.getVideoBytesTotal();
			var cbStart = this.impl.getVideoStartBytes();
			var cbEnd   = this.impl.getVideoBytesLoaded();
			return (cbStart + cbEnd) / (cbStart + cbTotal) * 100 || 0;
		},

		'getFullscreen': function () {
			return false;
		},

		'setFullscreen': function (bool) {
		},

		'mute': function () {
			this.impl.mute();
		},

		'unmute': function () {
			this.impl.unMute();
		},

		'isMute': function () {
			return !!this.impl.isMuted();
		},

		'close': function () {
			this.initialized = false;
			this.state       = states.UNINITIALIZED;
			this.closed      = true;
			this.impl        = null;
			$(this.container)[this.ownContainer ? 'remove' : 'empty']();
		}
	};

	return YouTube;
})();

// Linko.pl - end of file 'player/YouTube.js'
// Linko.pl - start of file 'Playlist.js'

Linko.Playlist = (function () {
	var logger = Linko.log.getLogger('Linko.Playlist');

	var Playlist = function (args) {
		args = Linko.util.extend(false, {
			'from': {
				'type': 'virtual'
			},
			'title':      '',
			'filename':   null,
			'index':      0,
			'tracks':     [],
			'load':       true,
			'trackCount': null
		}, args);

		switch (args.from.type) {
		case 'device':
			if (!args.from.device) {
				logger.error('constructor: args.from.device is not set');
				return null;
			}
			if (!args.from.dbId) {
				logger.error('constructor: args.from.dbId is not set');
				return null;
			}
			break;
		case 'itunes':
		case 'youtube':
		case 'virtual':
			// nothing to check
			break;
		default:
			logger.error('constructor: invalid args.from.type:', args.from.type);
			return null;
		}

		this.from     = args.from;
		this.title    = args.title;
		this.filename = args.filename;
		this.index    = args.index;
		this.tracks   = args.tracks;
		this.trackCount = args.trackCount;
		if (this.tracks) {
			this.trackCount = this.tracks.length;
		}

		this._saved = this._normalizeTracks(this.tracks);

		this.indexHistory = [];

		this.id = null;
		switch (this.from.type) {
		case 'device':
			this.id = Linko.util.hash(this.from.dbId);
			break;
		case 'itunes':
		case 'youtube':
		case 'virtual':
			this.id = Linko.util.hash(this.title || Linko.util.uuid());
			break;
		}

		this.es = new Linko.EventSource([
			// this.tracks changed
			'tracksChange',

			'getURL',

			// this.tracks[this.index] changed
			'activeTrackChange'
		]);

		var self = this;
		this.es.addEventListener(-1, 'tracksChange', function () {
			self.trackCount = self.tracks.length;
		});

		this._tracksChanged = this._makeTracksChanged();

		if (args.load) {
			this.load();
		}
	};

	Playlist.logger = logger;

	Playlist.prototype._getDB = function () {
		if (!this._db) {
			if (this.from.type !== 'device') {
				logger.warn('_getDB: this.from.type !== \'device\'');
				return null;
			}
			this._db = this.from.device.getDatabase();
			if (!this._db) {
				logger.error('_getDB: device.getDatabase() failed');
				return null;
			}
		}
		return this._db;
	};

	Playlist.prototype._capIndex = function (min, max) {
		if (!min) {
			min = 0;
		}
		if (!max) {
			max = this.tracks ? this.tracks.length - 1 : 0;
		}

		return function (i) {
			i = Math.floor(i);
			if (typeof i !== 'number' || isNaN(i)) {
				return min;
			}
			if (i < min) {
				return min;
			}
			if (i > max) {
				return max;
			}
			return i;
		};
	};

	Playlist.prototype.getTracks = function () {
		if (!this.tracks) {
			this.load();
		}
		if (!this.tracks) {
			this.tracks = [];
		}
		this.index = this._capIndex()(this.index);
		return this.tracks;
	};

	Playlist.prototype.load = function () {
		switch (this.from.type) {
		case 'virtual':
			logger.debug('load: type = \'virtual\', noop');
			return true;
		case 'itunes':
			var tracks = Linko.iTunes.cache.getTracks(this.from.itunesId) || [];
			tracks = tracks.map(function (track) {
				return {
					'title': track.name,
					'album': track.album,
					'artist': track.artist,
					'duration': track.duration / 10
				};
			});
			this.tracks = tracks;
			this._saved = this._normalizeTracks(this.tracks);
			this._tracksChanged();
			return true;
		case 'youtube':
			logger.error('load: not implemented for type', this.from.type);
			return false;
		}

		var db = this._getDB();
		if (!db) {
			logger.error('load: this._getDB() failed');
			return false;
		}

		var rows = db.select({
			'cols': [
				'files.id AS fileId',
				'files.name AS filename',
				'audio.artist',
				'audio.album',
				'audio.title',
				'audio.duration / 10 AS duration'
			],
			'tables': [
				'playlist',
				'LEFT JOIN files ON\n' +
				'  playlist.fileRef = files.id',
				'LEFT JOIN audio ON\n' +
				'  playlist.fileRef = audio.id'
			],
			'where': [
				'playlist.id = ?'
			],
			'bind': [
				this.from.dbId
			]
		});
		if (!rows) {
			logger.error('load: db.select() failed');
			return false;
		}
		this.tracks = rows;
		/*
		var self = this;
		this.tracks.forEach(function (track) {
			//track.device = self;
			//track.url = self.from.device.getResource(track.fileId);
		});
		*/
		this._saved = this._normalizeTracks(this.tracks);
		this._tracksChanged();
		return true;
	};

	Playlist.prototype._normalizeTracks = function (tracks) {
		if (!Array.isArray(tracks)) {
			return tracks;
		}
		return tracks.map(function (track) {
			return {
				//'fileId': track.fileId,
				'artist': track.artist,
				'album':  track.album,
				'title':  track.title
			};
		});
	};

	Playlist.prototype._makeTracksChanged = function () {
		var n = 0;
		return function () {
			this.trackCount = this.tracks.length;
			++n;
			var self = this;
			setTimeout(function () {
				if (n > 0) {
					n = 0;
					self.es.dispatchEvent('tracksChange');
				}
			}, 0);
		};
	};

	Playlist.prototype.add = function (track, pos) {
		this.getTracks();
		if (arguments.length < 2) {
			pos = this.tracks.length;
		}
		pos = this._capIndex(0, this.tracks.length)(pos);
		this.tracks.splice(pos, 0, track);
		if (pos <= this.index) {
			++this.index;
		}
		this._tracksChanged();
		return true;
	};

	Playlist.prototype.remove = function (pos) {
		this.getTracks();
		if (this.tracks.length === 0) {
			return false;
		}
		pos = this._capIndex(0, this.tracks.length - 1)(pos);
		this.tracks.splice(pos, 1);
		this._tracksChanged();

		var currentAtEnd = this.index >= this.tracks.length;
		var currentDeleted = pos === this.index;

		if (currentAtEnd && currentDeleted) {
			// move to the previous track
			this.setIndex(this.tracks.length - 1);
		}
		else if (currentAtEnd) {
			// current track remains the same, just the index changes
			--this.index;
		}
		else if (currentDeleted) {
			// index remains the same, track changes
			this.es.dispatchEvent('activeTrackChange');
		}
		else if (pos <= this.index) {
			--this.index;
		}
		return true;
	};

	Playlist.prototype.move = function (from, to) {
		this.getTracks();
		from = this._capIndex(0, this.tracks.length - 1)(from);
		to   = this._capIndex(0, this.tracks.length - 1)(to);

		if (from === to) {
			return false;
		}

		if (this.tracks.length === 0) {
			return false;
		}

		if (this.tracks.length === 1) {
			return true;
		}

		if (this.index === from) {
			this.index = to;
		}
		else if (this.index > from && this.index <= to) {
			--this.index;
		}

		var track = this.tracks[from];
		this.tracks.splice(from, 1);
		this.tracks.splice(to, 0, track);
		this._tracksChanged();

		return true;
	};

	Playlist.prototype.clear = function () {
		this.tracks = [];
		this._tracksChanged();
		this.setIndex(0);
		return true;
	};

	Playlist.prototype.setIndex = function (pos) {
		this.getTracks();
		pos = this._capIndex(0, this.tracks.length - 1)(pos);
		//if (this.index === pos) {
		//	return;
		//}
		this.index = pos;
		this.es.dispatchEvent('activeTrackChange');
	};

	Playlist.prototype.isPlayable = function (track) {
		if (!track) {
			return false;
		}
		if (track.url) {
			return true;
		}
		if (track._noURL) {
			return false;
		}
		return null;
	};

	Playlist.prototype.getTrackURL = function (track) {
		if (!track.url) {
			if (track._noURL) {
				return null;
			}

			// use metadata to find fileId
			if (!track.fileId && track.title) {
				var opts = {
					'cols':   ['id'],
					'tables': ['audio'],
					'where':  ['audio.title = ?'],
					'bind':   [track.title]
				};
				if (track.artist) {
					opts.where.push('audio.artist = ?');
					opts.bind.push(track.artist);
				}
				if (track.album) {
					opts.where.push('audio.album = ?');
					opts.bind.push(track.album);
				}
				if (!Linko.compoundDB.init()) {
					logger.warn('getTrackURL: compoundDB.init() failed');
				}
				else {
					var rows = Linko.deviceManager.computer.getDatabase().select(opts);
					if (!rows) {
						logger.warn('getTrackURL: compoundDB.db.select() failed');
					}
					else if (rows.length > 0) {
						track.fileId = rows[0].id;
						track.deviceId = Linko.deviceManager.computer.getId();
					}
				}
			}
			if (!track.url && track.fileId) {
				if (track.deviceId) {
					var device = Linko.deviceManager.getDeviceById(track.deviceId);
					if (device) {
						track.url = device.getResource(track.fileId);
					}
				}
				else if (this.from.device) {
					track.url = this.from.device.getResource(track.fileId);
				}
				else {
					track.url = Linko.compoundDB.getFileURL(track.fileId);
				}
			}

			var index = -1;
			Linko.util.each(this.tracks, function (i, t) {
				if (t === track) {
					index = i;
					return false;
				}
			});
			if (index !== -1) {
				var self = this;
				setTimeout(function () {
					self.es.dispatchEvent('getURL', [index, !!track.url]);
				}, 0);
			}
			if (!track.url) {
				track._noURL = true;
				return null;
			}
		}
		return track.url;
	};

	Playlist.prototype.activeTrack = function () {
		this.getTracks();
		if (this.tracks.length === 0) {
			return null;
		}
		return this.tracks[this.index];
	};

	Playlist.prototype.prev = function (shuffle, repeat) {
		this.getTracks();
		if (this.tracks.length === 0) {
			return false;
		}

		var newIndex = null;
		if (this.indexHistory.length > 0) {
			newIndex = this.indexHistory.pop();
		}
		else if (shuffle) {
			if (this.tracks.length > 1) {
				newIndex = Math.floor(Math.random() * this.tracks.length - 1);
				if (newIndex >= this.index) {
					++newIndex;
				}
			}
		}
		else {
			if (this.index > 0) {
				newIndex = this.index - 1;
			}
			else {
				if (!repeat) {
					return false;
				}
				newIndex = this.tracks.length - 1;
			}
		}
		this.setIndex(newIndex);
		return true;
	};

	Playlist.prototype.next = function (shuffle, repeat) {
		this.getTracks();
		if (this.tracks.length === 0) {
			return false;
		}
		this.indexHistory.push(this.index);
		var newIndex = 0;
		if (shuffle) {
			if (this.tracks.length > 1) {
				newIndex = Math.floor(Math.random() * (this.tracks.length - 1));
				if (newIndex >= this.index) {
					++newIndex;
				}
			}
		}
		else {
			if (this.index + 1 >= this.tracks.length) {
				if (!repeat) {
					return false;
				}
				newIndex = 0;
			}
			else {
				newIndex = this.index + 1;
			}
		}
		this.setIndex(newIndex);
		return true;
	};

	Playlist.prototype.unsavedChanges = function () {
		return !Linko.util.eq(this._normalizeTracks(this.tracks), this._saved);
	};

	// Save this.tracks to the DB
	Playlist.prototype.commit = function () {
		if (this.from.type !== 'device') {
			logger.info('commit: this.from.type !== \'device\'');
			return true;
		}
		/*
		if (Linko.util.eq(this.saved, this.track)) {
			logger.info('commit: no unsaved changes');
			return true;
		}
		*/
		logger.info('commit: starting...');

		var db = this._getDB();
		if (!db) {
			logger.error('commit: this._getDB() failed');
			return false;
		}

		if (!db.execute('BEGIN TRANSACTION;')) {
			logger.error('commit: BEGIN TRANSACTION failed');
			return false;
		}

		var q = db.prepare(
			'DELETE FROM\n' +
			'  playlist\n' +
			'WHERE\n' +
			'  id = ?;'
		);
		if (!q) {
			logger.error('commit: db.prepare() failed');
			return false;
		}
		if (!q.bind([this.from.dbId])) {
			logger.error('commit: q.bind() failed');
			return false;
		}
		if (!q.execute()) {
			logger.error('commit: q.execute() failed');
			return false;
		}

		var self = this;
		var ok = Linko.util.each(this.tracks, function (pos, track) {
			var q = db.prepare(
				'INSERT INTO\n' +
				'  playlist\n' +
				'  (id, category, fileRef, pos, extend) VALUES\n' +
				'  (?, ?, ?, ?, "");'
			);
			if (!q) {
				logger.error('commit: db.prepare(INSERT...) failed');
				return false;
			}
			if (!q.bind([self.from.dbId, 3, track.fileId, pos])) {
				logger.error('commit: q.bind() failed');
				return false;
			}
			if (!q.execute()) {
				logger.error('commit: q.execute() failed');
				return false;
			}
		});
		if (!ok) {
			logger.error('commit: failed to INSERT');
			return false;
		}

		if (!db.execute('COMMIT TRANSACTION;')) {
			logger.error('commit: END TRANSACTION failed');
			return false;
		}

		logger.info('commit: OK');
		return true;
	};

	Playlist.prototype.save = function () {
		if (this.from.type !== 'device') {
			logger.info('save: this.from.type !== \'device\'');
			return true;
		}
		if (!this.commit()) {
			logger.error('save: this.commit() failed');
			return false;
		}

		logger.info('save: calling device.genericInvoke(\'Playlist.Save\')...');
		this.from.device.genericInvoke('Playlist.Save', this.from.dbId);
		logger.info('save: done');
		this._saved = this._normalizeTracks(this.tracks);
		return true;
	};

	Playlist.prototype.saveTo = function (device) {
		if (this.from.type !== 'device') {
			logger.error('saveTo: only implemented for device lists (this.from.type === \'' + this.from.type + '\')');
			return false;
		}
		if (!device) {
			logger.error('saveTo: no device given');
			return false;
		}
		if (!this.filename) {
			logger.error('saveTo: no filename - this.filename =', this.filename);
			return false;
		}

		var db = device.getDatabase();
		if (!db) {
			logger.error('saveTo: device.getDatabase() failed');
			return false;
		}
		var rows = db.select({
			'cols':   ['id'],
			'tables': ['files'],
			'where':  ['name = ?'],
			'bind':   [this.filename]
		});
		if (!rows) {
			logger.error('saveTo: db.select() failed');
			return false;
		}

		var targetId = null;
		if (rows.length > 0) {
			targetId = rows[0].id;
			logger.info('saveTo: playlist exists already, filename =', this.filename, '| id =', targetId);
		}
		else {
			var basename = this.filename.replace(/\.[^.]*$/, '');
			logger.info('saveTo: calling device.genericInvoke(\'Playlist.Create\', \'' + basename + '\')...');
			targetId = device.genericInvoke('Playlist.Create', basename);
			if (!targetId || targetId === '0') {
				logger.error('saveTo: device.genericInvoke(\'Playlist.Create\', \'' + basename + '\') failed, return value:', targetId);
				return false;
			}
			logger.info('saveTo: playlist created, id =', targetId);
		}

		logger.info('saveTo: starting download...');

		//Linko.downloader.start(this.tracks.map(function (track) {
			// TODO
		//}));

		/*
		Linko.downloader.start([{
			'type': 'playlist',
			'sourceDevice': this.from.device,
			'targetDevice': device,
			'playlistSourceId': this.from.dbId,
			'playlistTargetId': targetId
		}]);
		*/

		logger.info('saveTo: OK');

		return true;
	};

	Playlist.prototype.refresh = function (callback) {
		if (!callback) {
			callback = Linko.noop;
		}
		switch (this.from.type) {
		case 'itunes':
			var self = this;
			Linko.iTunes.getTracks(this.from.itunesId, function (tracks) {
				var ok = tracks && !self.load();
				callback(!!ok);
			});
			return;
		case 'device':
			this.from.device.genericInvoke('Playlist.Refresh', this.from.dbId);
			this.load();
			return void callback(true);
		default:
			logger.info('refresh: only implemented for \'device\' and \'itunes\'');
			return void callback(true);
		}
	};

	Playlist.prototype.removeFile = function () {
		if (this.from.type !== 'device') {
			logger.error('removeFile: this.from.type !== \'device\'');
			return false;
		}
		logger.info('removeFile: calling device.genericInvoke(\'File.Delete\', \'' + this.from.dbId + '\')...');
		this.from.device.genericInvoke('File.Delete', this.from.dbId);
		logger.info('removeFile: OK');
		this._saved = [];
		return true;
	};

	Playlist.prototype.share = function () {
		logger.error('share: not implemented');
		return false;
	};

	return Playlist;
})();

// Linko.pl - end of file 'Playlist.js'
// Linko.pl - start of file 'ResultSet.js'

Linko.ResultSet = (function () {
	var logger = Linko.log.getLogger('Linko.ResultSet');
	var ResultSet = function (impl) {
		if (!impl) {
			logger.error('constructor: no impl');
			return null;
		}

		this.impl        = impl;
		this.columnCount = null;
	};

	ResultSet.logger = logger;

	/**
	 * Get a column from the next row.
	 * @param {Number} index first index is 0
	 */
	ResultSet.prototype.getColumn = function (index) {
		try {
			return this.impl.Column(index);
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getColumn');
		}
		return null;
	};

	ResultSet.prototype.close = function () {
		try {
			this.impl.Close();
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'close');
		}
		return false;
	};

	ResultSet.prototype.getColumnCount = function () {
		if (this.columnCount === null) {
			try {
				this.columnCount = this.impl.ColumnCount;
			}
			catch (e) {
				Linko.system.pluginError(e, logger, 'getColumnCount');
				this.columnCount = null;
			}
		}

		return this.columnCount;
	};

	/**
	 * Get the name of a column.
	 * @param {Number} index
	 * @return {String}
	 */
	ResultSet.prototype.getColumnName = function (index) {
		try {
			return this.impl.ColumnName(index);
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getColumnName');
		}
		return null;
	};

	/**
	 * Get the names of all columns.
	 * @return {String[]}
	 */
	ResultSet.prototype.getNames = function () {
		var cols = this.getColumnCount();
		if (cols === null) {
			logger.error('getNames: this.getColumnCount() failed');
			return null;
		}

		var out = [];

		for (var i = 0; i < cols; ++i) {
			var name = this.getColumnName(i);
			if (!name) {
				logger.error('getNames: this.getColumnName(' + i + ') failed');
				return null;
			}
			out.push(name);
		}

		return out;
	};

	ResultSet.prototype.isValidRow = function () {
		try {
			return this.impl.IsValidRow();
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'isValidRow');
		}
		return false;
	};

	ResultSet.prototype.getRow = function (asObject) {
		var cols = this.getColumnCount();
		if (cols === null) {
			logger.error('getRow: this.getColumnCount() failed');
			return null;
		}

		var out = asObject ? {} : [];

		for (var i = 0; i < cols; ++i) {
			var val = this.getColumn(i);
			if (val === null) {
				logger.error('getRow: getColumn(' + i + ') failed');
				return null;
			}
			if (asObject) {
				var name = this.getColumnName(i);
				if (!name) {
					logger.error('getRow: this.getColumnName(' + i + ') failed');
					return null;
				}
				out[this.getColumnName(i)] = val;
			}
			else {
				out.push(val);
			}
		}

		return out;
	};

	ResultSet.prototype.getRows = function (asObject) {
		var rows = [];
		while (this.isValidRow()) {
			var row = this.getRow(asObject);
			if (!row) {
				logger.error('getRows: this.getRow() failed');
				return null;
			}
			rows.push(row);
			if (!this.next()) {
				logger.error('getRows: this.next() failed');
				return null;
			}
		}
		if (!this.close()) {
			logger.warn('getRows: this.close() failed');
			return null;
		}
		return rows;
	};

	ResultSet.prototype.next = function () {
		try {
			this.impl.Next();
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'next');
		}
		return false;
	};

	return ResultSet;
})();

// Linko.pl - end of file 'ResultSet.js'
// Linko.pl - start of file 'sites/common.js'

Linko.sites = {};

// Linko.pl - end of file 'sites/common.js'
// Linko.pl - start of file 'sites/AdBase.js'

Linko.sites.AdBase = (function () {
	/**
	 * Ad database.
	 * @param {Object} [options]
	 * @param {String} [options.apiURL]
	 */
	var AdBase = function (options) {
		options = Linko.util.extend({}, options);

		this.apiURL = options.apiURL || 'http://' + document.location.host + '/application/playlist/api/ad.php';
	};

	(function () {
		var methods = {
			/**
			 * Get banner ads.
			 * @param {Object} [options]
			 * @param {String} [options.keywords] Keywords to use when selecting the ads
			 * @param {Number} [options.minWidth=0] Minimum banner width
			 * @param {Number} [options.minHeight=0] Minimum banner height
			 * @param {Number} [options.maxWidth=9001] Maximum banner width
			 * @param {Number} [options.maxHeight=9001] Maximum banner height
			 * @param {Number} [options.maxNumber=1] Maximum number of results
			 */
			'getBannerAds' : {},

			/**
			 * Get plain text product ads.
			 * @param {Object} [options]
			 * @param {String} [options.keywords] Keywords to use when selecting the ads
			 * @param {Number} [options.maxNumber=1] Maximum number of results
			 */
			'getProductAds': {}
		};

		Linko.util.each(methods, function (methodName, data) {
			AdBase.prototype[methodName] = function (options) {
				return this.request(Linko.util.extend({
					'cache': true,
					'params': {
						'action': data.apiMethod || methodName
					},
					'url': this.apiURL
				}, options));
			};
		});
	})();

	AdBase.prototype.request = function (options) {
		return Linko.util.ajax(Linko.util.extend({
			'parseJSON': true,
			'createXHR': Linko.util.createXHR
		}, options));
	};

	return AdBase;
})();

// Linko.pl - end of file 'sites/AdBase.js'
// Linko.pl - start of file 'sites/Facebook.js'

Linko.sites.Facebook = (function () {
	var logger = Linko.log.getLogger('Linko.sites.Facebook');

	var Facebook = function (args) {
		if (typeof args === 'string') {
			args = { 'appId':args };
		}

		args = Linko.util.extend({}, args);

		if (!args.appId) {
			logger.error('constructor: no appId given');
			return null;
		}

		this.appId  = args.appId;
		this.apiKey = args.apiKey || null;
		this.secret = args.secret || null;

		if (typeof args.uploadCallback === 'function') {
			this.uploadCallback = args.uploadCallback;
		}

		init(this.appId);
	};

	var init = function (appId) {
		// Ensure initialization is done only once.
		init = function () {
			logger.debug('init: called already');
		};

		try {
			var fbDiv = document.getElementById('fb-root');

			if (!fbDiv) {
				fbDiv = document.createElement('div');
				fbDiv.setAttribute('id', 'fb-root');
				document.body.appendChild(fbDiv);
			}

			var script = document.createElement('script');
			script.src = document.location.protocol + '//connect.facebook.net/en_US/all.js';
			script.async = true;
			fbDiv.appendChild(script);
		}
		catch (e) {
			logger.warn('init: exception when including Facebook\'s JavaScript SDK:', e);
		}

		window.fbAsyncInit = function() {
			FB.init({
				appId : appId || '132829203409322',
				status: true,  // check login status
				cookie: true,  // enable cookies to allow the server to access the session
				xfbml : false  // parse XFBML
			});

			logger.info('window.fbAsyncInit: OK');
		};
	};

	var defaultCallback = function () {
		logger.debug('no callback given', arguments);
	};

	var renamers = Linko.util.makeRenamers({
		'photo': {
			'.id'     : 'id',
			'.width'  : 'width',
			'.height' : 'height',
			'.src'    : 'source',
			'.title'  : 'name'
		},
		'album': {
			'.id'         : 'id',
			'.title'      : 'name',
			'.description': 'description',
			'.itemCount'  : 'count',
			'.privacy'    : 'privacy'
		},
		'video': {
			'.id'         : 'id',
			'.title'      : 'message',
			'.description': 'description'
		},
		'user' : {
			'.id'     : 'id',
			'.name'   : {
				'.first': 'first_name',
				'.last' : 'last_name',
				'.full' : 'name'
			},
			'.image'  : {
				'path'  : 'id',
				'filter': function (id) {
					return 'http://graph.facebook.com/' + id + '/picture';
				}
			}
		}
	});

	// Some perms that are probably needed:
	//   ['publish_stream', 'photo_upload', 'user_photos', 'friends_photos', 'user_videos', 'friends_videos', 'user_photo_video_tags', 'friends_photo_video_tags']
	// Full list at http://developers.facebook.com/docs/authentication/permissions
	Facebook.prototype.login = function (perms, callbacks, force) {
		if (Array.isArray(callbacks)) {
			var temp = perms;
			perms = callbacks;
			callbacks = temp;
		}

		callbacks = Linko.util.extendcb({
			'success': function () {
				logger.info('login: success callback');
			},
			'error': function () {
				logger.warn('login: error callback');
			}
		}, callbacks);

		if (FB.getSession() && !force) {
			Linko.util.callAll(callbacks.success, [FB.getSession()]);
			return;
		}

		if (!perms) {
			perms = [];
		}

		FB.login(
			function (session) {
				Linko.util.callAll(callbacks[session ? 'success' : 'error'], [session]);
			},
			{
				'perms': Array.isArray(perms) ? perms.join(',') : perms
			}
		);
	};

	Facebook.prototype.loggedIn = function () {
		return !!FB.getSession();
	};

	Facebook.prototype.loginStatus = function (callback) {
		FB.getLoginStatus(callback || defaultCallback);
	};

	Facebook.prototype.logout = function (callback) {
		FB.logout(callback || defaultCallback);
	};

	Facebook.prototype.getUserVideoLimits = function (callbacks) {
		var session = FB.getSession();

		if (!session) {
			logger.error('getUserVideoLimits: No session');
			return null;
		}

		var params = {
			'api_key'    : this.apiKey,
			'call_id'    : (new Date()).getTime().toString(),
			'v'          : '1.0',
			'session_key': session.access_token.split('|')[1],
			'format'     : 'JSON',
			'method'     : 'Video.getUploadLimits'
		};

		params.sig = this.createSignature(params);

		return Linko.util.ajax({
			'url'      : 'http://api.facebook.com/restserver.php',
			'parseJSON': true,
			'params'   : params,
			'callbacks': callbacks
		});
	};

	Facebook.prototype.createSignature = function (params) {
		var keys = Linko.util.keys(params);
		keys.sort();

		var sig = keys.map(function (key) { return key + '=' + params[key]; }).join('');
		sig += this.secret;

		return Linko.util.md5(sig);
	};

	Facebook.prototype.uploadCallback = function (ok, mediaType, id) {
		logger.info('uploadCallback:', arguments);
	};

	Facebook.prototype.getFriends = function (params, callbacks) {
		if (typeof params === 'string') {
			params = { 'id':params };
		}

		params = Linko.util.extend({}, params);

		var id = params && params.id || 'me';
		delete params.id;
		if (typeof id !== 'string' && id.join) {
			id = id.join(',');
		}

		return this.request({
			'method'   : 'friends',
			'params'   : Linko.util.extend({'ids':id}, params),
			'async'    : !!callbacks,
			'callbacks': Linko.util.extendcb({
				'filter': function (json) {
					var out = {};
					Linko.util.each(json, function (k, v) {
						out[k] = v.data.map(renamers.user);
					});
					return out;
				}
			}, callbacks)
		});
	};

	Facebook.prototype.getAlbums = function (params, callbacks) {
		if (typeof params === 'string') {
			params = { 'id':params };
		}

		params = Linko.util.extend({}, params);

		var id = params && params.id || 'me';
		delete params.id;
		if (typeof id !== 'string' && id.join) {
			id = id.join(',');
		}

		return this.request({
			'method'   : 'albums',
			'params'   : Linko.util.extend({'ids':id}, params),
			'async'    : !!callbacks,
			'callbacks': Linko.util.extendcb({
				'filter': function (json) {
					var out = {};
					Linko.util.each(json, function (k, v) {
						out[k] = v.data.map(renamers.album);
					});
					return out;
				}
			}, callbacks)
		});
	};

	// params.id can be either a user id or an album id
	Facebook.prototype.getPhotos = function (params, callbacks) {
		if (typeof params === 'string') {
			params = { 'id':params };
		}

		params = Linko.util.extend({}, params);

		var id = params && params.id || 'me';
		delete params.id;
		if (typeof id !== 'string' && id.join) {
			id = id.join(',');
		}

		return this.request({
			'method'   : 'photos',
			'params'   : Linko.util.extend({'ids':id}, params),
			'async'    : !!callbacks,
			'callbacks': Linko.util.extendcb({
				'filter': function (json) {
					var out = {};
					Linko.util.each(json, function (k, v) {
						out[k] = v.data.map(renamers.photo);
					});
					return out;
				}
			}, callbacks)
		});
	};

	Facebook.prototype.getVideos = function (params, callbacks) {
		if (typeof params === 'string') {
			params = { 'id':params };
		}

		params = Linko.util.extend({}, params);

		var id = params && params.id || 'me';
		delete params.id;
		if (typeof id !== 'string' && id.join) {
			id = id.join(',');
		}

		return this.request({
			'method'   : 'videos',
			'params'   : Linko.util.extend({'ids':id}, params),
			'async'    : !!callbacks,
			'callbacks': Linko.util.extendcb({
				'filter': function (json) {
					var obj = {};
					Linko.util.each(json, function (k, v) {
						obj[k] = v.data.map(renamers.video);
					});
					return obj;
				}
			}, callbacks)
		});
	};

	Facebook.prototype.createAlbum = function (albumName, callbacks) {
		return this.request({
			'method'    : 'me/albums',
			'httpMethod': 'POST',
			'params'    : {
				'name'   : albumName,
				'message': ''
			},
			'callbacks' : callbacks
		});
	};

	Facebook.prototype.makePictureURL = function (id) {
		return 'https://graph.facebook.com/' + id + '/picture?access_token=' + FB.getSession().access_token;
	};

	// type should be one of ['user']
	// TODO: more types
	Facebook.prototype.search = function (type, q, params, callbacks) {
		if (['user'].indexOf(type) === -1) {
			logger.info('search: invalid type: "' + type + '"');
			Linko.util.callAll(callbacks && callbacks.error);
			return null;
		}

		return this.request({
			'method'   : 'search',
			'params'   : params,
			'async'    : !!callbacks,
			'callbacks': Linko.util.extendcb({
				'filter': function (data) {
					return data.data.map(renamers[type]);
				}
			}, callbacks)
		});
	};

	Facebook.prototype.request = function (args) {
		args = Linko.util.extend({
			'params':    {},
			'parseJSON': true
		}, args);

		if (args.params['page'] || args.params['pageSize']) {
			var page     = args.params['page']     ||  1;
			var pageSize = args.params['pageSize'] || 10;

			if (pageSize !== -1) {
				delete args.params['page'];
				delete args.params['pageSize'];

				args.params['offset'] = (page - 1) * pageSize;
				args.params['limit']  = pageSize;
			}
		}

		var s = FB.getSession();
		if (s) {
			args.params['access_token'] = s.access_token;
		}

		args.url = 'https://graph.facebook.com/' + (args.method || 'me');
		delete args.method;

		args.method = args.httpMethod || 'GET';
		delete args.httpMethod;

		return Linko.util.ajax(args);
	};

	(function () {
		var FacebookDevice = function (fb) {
			this.fb = fb;
		};

		Facebook.prototype.getDeviceObject = function () {
			return new FacebookDevice(this);
		};

		FacebookDevice.prototype.connectionMode = function () {
			return 10;
		};

		FacebookDevice.prototype.getName = function () {
			return 'Facebook';
		};

		FacebookDevice.prototype.getId = function () {
			return 'facebook';
		};

		FacebookDevice.prototype.getAttribute = function (attribute) {
			return {
				'category': 10
			}[attribute];
		};

		FacebookDevice.prototype.downloadFile = function () {
			var self = this;

			var downloadFile = {
				'getTargetDeviceType': function () {
					return 1; // virtual device
				},

				'setName': function (name) {
					downloadFile.name = name;
				},

				'setMimeType': function (mime) {
					downloadFile.mimeType = mime;
				},

				'open': function (item) {
					logger.info('FacebookDevice.open');
					Linko.util.extend(false, downloadFile, item);
				},

				'begin': function (async) {
					logger.info('FacebookDevice.begin');

					if (!downloadFile.name) {
						logger.warn('FacebookDevice.begin: device.name is not set');
					}

					if (!downloadFile.mimeType) {
						logger.warn('FacebookDevice.begin: device.mimeType is not set');
					}

					if (!downloadFile.src) {
						logger.error('FacebookDevice.begin: device.src is not set, aborting');
						return;
					}

					try {
						var session = FB.getSession();

						if (!session) {
							throw 'no session';
						}

						var form = new Linko.WebForm();
						form.downloadFile = downloadFile;

						if (!form) {
							throw 'creating WebForm failed';
						}

						var params = {};

						switch (downloadFile.mimeType) {
						case 'image':
							params.access_token = session.access_token;

							if (downloadFile.caption) {
								params.message = downloadFile.caption;
							}

							// TODO
							//if (!Linko.system.isMac) {
							//	downloadFile.src = Linko.image.scale('aspect;fill', downloadFile.src, 720, 720, 'transparent', 'png');
							//}

							form.open('https://graph.facebook.com/' + (downloadFile.target || 'me') + '/photos');
							break;
						case 'video':
							// Facebook's Graph API doesn't support video uploads yet, so we use the old API
							params = {
								'api_key'    : self.fb.apiKey,
								'call_id'    : (new Date()).getTime().toString(),
								'v'          : '1.0',
								'session_key': session.access_token.split('|')[1],
								'format'     : 'JSON',
								'method'     : 'facebook.video.upload'
							};

							if (downloadFile.caption) {
								params.title = downloadFile.caption;
							}

							params.sig = self.fb.createSignature(params);

							form.open('http://api-video.facebook.com/restserver.php');
							break;
						default:
							throw 'invalid mimeType: ' + downloadFile.mimeType;
						}

						Linko.util.each(params, function (k,v) { form.addField(k,v); });
						form.addFile('source', downloadFile.src, downloadFile.name, downloadFile.mimeType);
						form.submit({
							'sourceDeviceId': Linko.deviceManager.computer.getId(),
							'targetDeviceId': self.getId()
						});
					}
					catch (e) {
						logger.error('FacebookDevice.begin: ' + e);
						throw e;
					}
				},

				'end': function (form) {
					logger.info('FacebookDevice.end', form.getReadyState(), form.getStatus());
					var response = form.getResponseText();
					var json = null;
					try {
						json = JSON.parse(response);
					}
					catch (e) {
						logger.error('FacebookDevice.end: Response is not valid JSON', response);
						self.fb.uploadCallback(false, downloadFile.mediaType);
						return;
					}

					// For photo uploads, json is:
					//     { "id":"..." }
					// and for video uploads:
					//     { "vid":"...", "title":"...", "link":"..." }

					if (json && json.id) {
						self.fb.uploadCallback(true, downloadFile.mediaType, json.id);
					}
					else if (json && json.vid) {
						self.fb.uploadCallback(true, downloadFile.mediaType, json.vid);
					}
					else {
						logger.error('FacebookDevice.end: Can\'t find id from response', json);
						self.fb.uploadCallback(false, downloadFile.mediaType, null);
					}
				}
			};

			return downloadFile;
		};
	})();

	Facebook.prototype.shareMediaLink = function (params, callback) {
		var attachment = {
			'name'   : params.title || '',
			'href'   : params.src,
			'caption': params.caption || '',
			'media'  : params.media
		};

		FB.ui({
			'method'             : 'stream.publish',
			'display'            : 'popup',
			'message'            : params.message,
			'attachment'         : attachment,
			'action_links'       : params.actionLinks,
			'user_message_prompt': params.userMessage
		}, callback);
	};

	Facebook.prototype.getUserInfo = function (params, callbacks) {
		if (typeof params === 'string') {
			params = { 'id':params };
		}

		params = Linko.util.extend({
			'id': 'me'
		}, params);

		// facebook fails
		var id = params.id;
		delete params.id;

		return this.request({
			'method'   : id,
			'async'    : !!callbacks,
			'params'   : params,
			'callbacks': Linko.util.extendcb({
				'filter': function (data) {
					return renamers.user(data);
				}
			}, callbacks)
		});
	};

	return Facebook;
})();

// Linko.pl - end of file 'sites/Facebook.js'
// Linko.pl - start of file 'sites/Flickr.js'

Linko.sites.Flickr = (function () {
	var cookieName = 'flickr-token';
	var instances = [];
	var logger = Linko.log.getLogger('Linko.sites.Flickr');

	var Flickr = function (args) {
		args = Linko.util.extend({}, args);

		if (!args.apiKey) {
			logger.warn('constructor: no apiKey given');
		}

		if (!args.secret) {
			logger.warn('constructor: no secret given');
		}

		this.apiKey = args.apiKey || '50499d11fff8ddb60434450c142c7f5f';
		this.secret = args.secret || '4638d7bc1fe39cc5';

		this.authToken    = null;
		this.sessionToken = null;
		this.authorized   = false;

		if (typeof args.uploadCallback === 'function') {
			this.uploadCallback = args.uploadCallback;
		}

		instances.push(this);

		var cookie = Linko.util.cookie.get(cookieName);
		if (!cookie) {
			return;
		}

		var self = this;
		var validateToken = function () {
			self.getTokenInfo(cookie, {
				'success': function (tokenInfo) {
					if (self.setSessionToken(cookie)) {
						logger.info('found cookie');
					}
					else {
						Linko.util.cookie.remove(cookieName);
					}
				},
				'error': function () {
					logger.info('removing invalid cookie');
					Linko.util.cookie.remove(cookieName);
				}
			});
		};
		if (Linko.system.initialized) {
			validateToken();
		}
		else {
			Linko.initQueue.push(validateToken);
		}
	};

	Flickr.logger = logger;

	Flickr.authorizationCallback = function (token) {
		logger.info('authorizationCallback: token = ' + token);
		for (var i = 0; i < instances.length; ++i) {
			if (instances[i].waitingForAuthToken) {
				instances[i].authTokenCallback_(token);
				return;
			}
		}

		logger.warn('authorizationCallback: got a token ("' + token + '") but nobody wants it :(');
	};

	Flickr.prototype.makeSignature = function (params) {
		if (!Linko.util.md5) {
			logger.error('makeSignature: plugin isn\'t ready');
			return null;
		}

		if (!params) {
			params = {};
		}

		var paramList = [];
		Linko.util.each(params, function (k, v) { paramList.push({k:k,v:v}); });

		paramList.sort(function (a, b) { return a.k < b.k ? -1 : a.k === b.k ? 0 : 1; });

		var str = this.secret + paramList.map(function (param) {
			return param.k + param.v;
		}).join('');
		return Linko.util.md5(str);
	};

	Flickr.prototype.uploadCallback = function () {
		logger.info('uploadCallback:', Linko.util.array(arguments));
	};

	// This adds the signature
	Flickr.prototype.makeQueryString = function (params, includeQuestionMark) {
		return Linko.util.makeQueryString(
			Linko.util.extend({
				'api_sig': this.makeSignature(params)
			}, params),
			includeQuestionMark
		);
	};

	Flickr.prototype.makeAuthURL = function (perms) {
		var queryString = this.makeQueryString({
			'api_key': this.apiKey,
			'perms'  : perms
		}, true);

		return 'http://flickr.com/services/auth/' + queryString;
	};

	Flickr.prototype.requestAuthToken = function (perms, callbacks) {
		if (!perms) {
			perms = 'read';
		}

		callbacks = Linko.util.extendcb({}, callbacks);

		if (!perms.match(/^(read|write|delete)$/)) {
			logger.error('requestAuthToken: invalid perms (' + perms + ')');
			Linko.util.callAll(callbacks.error);
			return;
		}

		this.authTokenCallback = function (token) {
			Linko.util.callAll(Linko.util.get(callbacks, token ? 'success' : 'error'));
		};

		logger.info('requestAuthToken: opening authorization window');
		var w = window.open(this.makeAuthURL(perms), '', 'height=500,width=900');
		var self = this;
		var waitForClose = setInterval(function () {
			if (!w.closed) {
				return;
			}

			clearInterval(waitForClose);
			if (self.waitingForAuthToken) {
				logger.info('requestAuthToken: window was closed');
				self.authTokenCallback_(false);
			}
		}, 500);
		this.waitingForAuthToken = true;
	};

	Flickr.prototype.setSessionToken = function (arg) {
		var token = arg;
		if (arg && arg.token) {
			token = arg.token;
		}

		if (!token) {
			logger.error('setSessionToken: no token given');
			return false;
		}

		this.sessionToken = token;
		this.authorized   = true;
		Linko.util.cookie.set(cookieName, token);
		return true;
	};

	Flickr.prototype.authTokenCallback_ = function (token) {
		logger.info('authTokenCallback_:', token);

		this.authToken           = token;
		this.waitingForAuthToken = false;

		Linko.util.callAll(this.authTokenCallback, token);
	};

	Flickr.prototype.requestSessionToken = function (callbacks, authToken) {
		if (!authToken && this.authToken) {
			authToken = this.authToken;
		}

		if (!authToken) {
			logger.error('requestSessionToken: no authToken');
			return false;
		}

		var self = this;
		return this.request({
			'url'      : 'http://api.flickr.com/services/rest/',
			'callbacks': Linko.util.extendcb({
				'beforeSuccess': function (arg) {
					self.setSessionToken(arg.auth.token._content);
				}
			}, callbacks),
			'params'   : {
				'method': 'flickr.auth.getToken',
				'frob'  : authToken
			}
		});
	};

	(function () {
		var FlickrDevice = function (flickr) {
			this.flickr = flickr;
		};

		Flickr.prototype.getDeviceObject = function () {
			return new FlickrDevice(this);
		};

		FlickrDevice.prototype.connectionMode = function () {
			return 10;
		};

		FlickrDevice.prototype.getName = function () {
			return 'Flickr';
		};

		FlickrDevice.prototype.getId = function () {
			return 'flickr';
		};

		FlickrDevice.prototype.getAttribute = function (attribute) {
			return {
				'session' : this.sessionToken,
				'category': 10
			}[attribute];
		};

		FlickrDevice.prototype.downloadFile = function () {
			var self = this;

			// Instead of returning the object literal directly, it is named
			// to make self-referencing easy.
			var downloadFile = {
				's_token'   : self.flickr.sessionToken,
				'src'       : undefined,
				'dest'      : undefined,
				'name'      : undefined,
				'mimeType'  : undefined,
				'onProgress': undefined,
				'metaupload': undefined,
				'fileupload': undefined,

				'getTargetDeviceType': function () {
					// Virtual device
					return 1;
				},

				'setName': function (name) {
					downloadFile.name = name;
				},

				'setMimeType': function (mime) {
					downloadFile.mimeType = mime;
				},

				'open': function (item) {
					logger.info('FlickrDeviceObject.open');
					if (item.src) {
						downloadFile.src = item.src;
					}
				},

				'begin': function (async) {
					logger.info('FlickrDevice.begin');

					if (!downloadFile.name) {
						logger.warn('FlickrDevice.begin: device.name is not set');
					}

					if (!downloadFile.mimeType) {
						logger.warn('FlickrDevice.begin: device.mimeType is not set');
					}

					if (!downloadFile.src) {
						logger.error('FlickrDevice.begin: device.src is not set, aborting');
						return;
					}

					try {
						var form = new Linko.WebForm();
						form.downloadFile = downloadFile;

						if (!form) {
							throw 'creating WebForm failed';
						}

						var params = {
							//'title'       : '',
							//'description' : '',
							//'is_public'   : 0|1,
							//'is_friend'   : 0|1,
							//'is_family'   : 0|1,
							//'content_type': 1|2|3,
							'hidden'      : '2',
							'auth_token'  : self.flickr.sessionToken,
							'api_key'     : self.flickr.apiKey
						};

						if (downloadFile.caption) {
							params.title = downloadFile.caption;
						}

						params['api_sig'] = self.flickr.makeSignature(params);

						form.open('http://api.flickr.com/services/upload/');
						Linko.util.each(params, function (k,v) { form.addField(k,v); });
						form.addFile('photo', downloadFile.src, downloadFile.name, downloadFile.mimeType);
						form.submit({
							'sourceDeviceId': Linko.deviceManager.computer.getId(),
							'targetDeviceId': self.getId()
						});
					}
					catch (e) {
						logger.error('FlickrDevice.begin: ' + e);
						throw e;
					}
				},

				'end': function (form) {
					logger.info('FlickrDevice.end', form.getReadyState(), form.getStatus(), form.getResponseText());

					var xml = form.getResponseText() || '';

					var id = xml.match(/<photoid>([^<]*)<\/photoid>/);
					var ok = !!(xml.match(/stat="ok"/) && id);
					if (!ok) {
						self.flickr.uploadCallback(false, downloadFile.mimeType, null);
					}
					else {
						self.flickr.uploadCallback(true, downloadFile.mimeType, id[1]);
					}
				}
			};

			return downloadFile;
		};
	})();

	Flickr.prototype.getTokenInfo = function (token, callbacks) {
		if (!token) {
			token = this.sessionToken;
		}

		if (!token) {
			logger.error('getTokenInfo: there is no token');
			return null;
		}

		return this.request({
			'params'   : {
				'method': 'flickr.auth.checkToken'
			},
			'token'    : token,
			'callbacks': callbacks
		});
	};

	var renamers = Linko.util.makeRenamers({
		'photo': {
			'.photoId' : 'id',
			'.serverId': 'server',
			'.farmId'  : 'farm',
			'.secretId': 'secret',
			'.authorId': 'owner',
			'.title'   : 'title',
			'.isPublic': { 'path':'ispublic', 'filter':function (b) { return !!b; } }
		},
		'photoList': {
			'.page'      : { 'path':'page'   , 'filter':Number },
			'.pageSize'  : { 'path':'perpage', 'filter':Number },
			'.totalItems': { 'path':'total'  , 'filter':Number },
			'.items'     : {
				'path'  : 'photo',
				'filter': function (photos) {
					return photos.map(renamers.photo);
				}
			}
		},
		'photoset': {
			'.albumId'   : 'id',
			'.title'     : ['title', '_content'],
			'.totalItems': { 'path':'photos', 'filter':Number }
		},
		'photosetPhotos': {
			'.albumId'   : 'id',
			'.page'      : { 'path':'page'   , 'filter':Number },
			'.pageSize'  : { 'path':'perpage', 'filter':Number },
			'.totalItems': { 'path':'total'  , 'filter':Number },
			'.items'     : {
				'path'  : 'photo',
				'filter': function (photos) {
					return photos.map(renamers.photo);
				}
			}
		},
		'size': {
			'.name'   : { 'path':'label' },
			'.width'  : { 'path':'width' , 'filter':Number },
			'.height' : { 'path':'height', 'filter':Number },
			'.url'    : { 'path':'source' }
		}
	});

	Flickr.prototype.authorize = function (perms, callbacks, force) {
		callbacks = Linko.util.extendcb({}, callbacks);

		if (this.authorized && !force) {
			logger.debug('authorize: authorized already');
			Linko.util.callAll(callbacks.success);
			return;
		}

		var self = this;
		this.requestAuthToken(perms, {
			'success': function (token) {
				self.requestSessionToken(callbacks);
			},
			'error': function () {
				Linko.util.callAll(Linko.util.get(callbacks, 'error'));
			}
		});
	};

	Flickr.prototype.search = function (q, params, callbacks) {
		if (!q) {
			return this.getFeed('recent', params, callbacks);
		}
		return this.request({
			'params'   : Linko.util.extend({
				'method': 'flickr.photos.search',
				'text'  : q || ''
			}, params),
			'callbacks': Linko.util.extendcb({
				'filter': function (json) {
					return renamers.photoList(json.photos);
				}
			}, callbacks)
		});
	};

	Flickr.prototype.sizes = function (photoId, params, callbacks) {
		if (!photoId) {
			Linko.util.callAll(callbacks && callbacks.error, 'no photoId');
			return null;
		}

		return this.request({
			'params'   : Linko.util.extend({
				'method'  : 'flickr.photos.getSizes',
				'photo_id': photoId
			}, params),
			'callbacks': Linko.util.extendcb({
				'filter': function (json) {
					return (json.sizes && json.sizes.size || []).map(renamers.size);
				}
			}, callbacks)
		});
	};

	Flickr.prototype.getFeed = (function () {
		var feedMethods = {
			'popular'         : 'flickr.interestingness.getList',
			'recent'          : 'flickr.photos.getRecent',
			'myPhotos'        : 'flickr.people.getPhotos',
			'myPhotosNotInSet': 'flickr.photos.getNotInSet'
		};

		return function (feed, params, callbacks) {
			if (!feed) {
				feed = 'popular';
			}

			var method = feedMethods[feed];

			if (!method) {
				Linko.util.callAll(callbacks && callbacks.error, 'invalid feed: "' + feed + '"');
				return null;
			}

			if (typeof callbacks === 'function') {
				callbacks = { 'success':callbacks };
			}

			return this.request({
				'params'   : Linko.util.extend({
					'method': method
				}, params),
				'callbacks': Linko.util.extendcb({
					'filter': function (json) {
						return renamers.photoList(json.photos);
					}
				}, callbacks)
			});
		};
	})();

	Flickr.prototype.createPhotoset = function (params, callbacks) {
		return this.request({
			'params': Linko.util.extend({
				'method':'flickr.photosets.create'
			}, params),
			'callbacks': callbacks
		});
	};

	Flickr.prototype.addPhotoToPhotoset = function (params, callbacks) {
		return this.request({
			'params': Linko.util.extend({
				'method': 'flickr.photosets.addPhoto'
			}, params),
			'callbacks': callbacks
		});
	};

	Flickr.prototype.getMyPhotosets = function (params, callbacks) {
		return this.request({
			'params': Linko.util.extend({
				'method': 'flickr.photosets.getList'
			}, params),
			'callbacks': Linko.util.extendcb({
				'filter': function (json) {
					return json.photosets.photoset.map(renamers.photoset);
				}
			}, callbacks)
		});
	};

	Flickr.prototype.getPhotosetContents = function (params, callbacks) {
		return this.request({
			'params': Linko.util.extend({
				'method': 'flickr.photosets.getPhotos'
			}, params),
			'callbacks': Linko.util.extendcb({
				'filter': function (json) {
					return renamers.photosetPhotos(json.photoset);
				}
			}, callbacks)
		});
	};

	Flickr.prototype.getMyPhotos = function (params, callbacks) {
		return this.getFeed('myPhotos', Linko.util.extend({'user_id':'me'}, params), callbacks);
	};

	Flickr.prototype.makePhotoURL = function (photo, size) {
		if (!(photo.farmId && photo.serverId && photo.photoId && photo.secretId))
			return null;

		var c = size && size.match(/^[stmb]$/) ? '_' + size : '';
		return 'http://farm' + photo.farmId + '.static.flickr.com/' + photo.serverId + '/' + photo.photoId + '_' + photo.secretId + c + '.jpg';
	};

	Flickr.prototype.getMyVideos = function (params, callbacks) {
		if (typeof callbacks === 'function') {
			callbacks = { 'success':callbacks };
		}

		return this.request({
			'params': Linko.util.extend({
				'method' : 'flickr.photos.search',
				'user_id': 'me',
				'media'  : 'videos'
			}, params),
			'callbacks': Linko.util.extendcb({
				'filter': function (json) {
					return renamers.photoList(json.photos);
				}
			}, callbacks)
		});
	};

	// If we're authorized, the sessionToken is sent automatically.
	Flickr.prototype.request = function (args) {
		args = Linko.util.extendcb({
			'method':    'GET',
			'url':       'http://api.flickr.com/services/rest/',
			'parseJSON': true,
			'params': {
				'format':         'json',
				'nojsoncallback': 1,
				'api_key':        this.apiKey
			},
			'callbacks': {
				'filter': function (json) {
					if (json.stat !== 'ok') {
						throw json;
					}
					return json;
				}
			}
		}, args);

		var token = this.authorized ? this.sessionToken : args.token;
		delete args.token;

		if (token) {
			args.params['auth_token'] = token;
		}

		if (typeof args.params['pageSize'] !== 'undefined') {
			args.params['per_page'] = args.params['pageSize'];
			delete args.params['pageSize'];
		}

		args.params['api_sig'] = this.makeSignature(args.params);

		return Linko.util.ajax(args);
	};

	return Flickr;
})();
// Linko.pl - end of file 'sites/Flickr.js'
// Linko.pl - start of file 'sites/MusicShop.js'

Linko.sites.MusicShop = (function () {
	/**
	 * An interface to 7Digital and SecuryCast.
	 * @param {Object} [options]
	 * @param {String} [options.shop='7Digital'] '7Digital' or 'SecuryCast'
	 * @param {String} [options.apiURL]
	 */
	var MusicShop = function (options) {
		options = Linko.util.extend({}, options);

		this.shop   = options.shop || '7Digital';
		this.apiURL = options.apiURL || 'http://' + document.location.host + '/application/playlist/api/';
	};

	(function () {
		var methods = {
			/**
			 * Try to find a track based on metadata.
			 * @param {Object} options
			 * @param {Object} options.track
			 * @param {String} [options.track.title]
			 * @param {String} [options.track.artist.name]
			 * @param {String} [options.track.album.title]
			 */
			'findTrack'           : { 'cache':true },
			'getAlbumsByArtist'   : { 'cache':true },
			'getAlbumsByTime'     : { 'cache':true },
			'getGenres'           : { 'cache':true },
			'getImage'            : { 'cache':true },
			'getNewAlbumsByTag'   : { 'cache':true },
			'getPreview'          : { 'cache':true },
			'getSinglesByArtist'  : { 'cache':true },
			'getTags'             : { 'cache':true },
			'getTagsByAlbum'      : { 'cache':true },
			'getTagsByArtist'     : { 'cache':true },
			'getTopAlbumsByTag'   : { 'cache':true },
			'getTopAlbumsByTime'  : { 'cache':true },
			'getTopArtistsByTag'  : { 'cache':true, 'apiMethod':'getArtistsByTag' },
			'getTopArtistsByTime' : { 'cache':true },
			'getTopTracksByArtist': { 'cache':true },
			'getTopTracksByTime'  : { 'cache':true },
			'getTracksByAlbum'    : { 'cache':true },
			'lookupAlbum'         : { 'cache':true },
			'lookupArtist'        : { 'cache':true },
			'lookupTrack'         : { 'cache':true },
			'search'              : { 'cache':true },
			'getShops'            : { 'cache':true }
		};

		Linko.util.each(methods, function (methodName, data) {
			MusicShop.prototype[methodName] = function (options) {
				return this.request(Linko.util.extend({
					'params': {
						'shop':   this.shop,
						'action': data.apiMethod || methodName
					},
					'cache': data.cache || null,
					'url': this.apiURL + 'catalogue.php'
				}, options));
			};
		});
	})();

	MusicShop.prototype.request = function (options) {
		return Linko.util.ajax(Linko.util.extend({
			'parseJSON': true,
			'params'   : {
				'shop': this.shop
			}
		}, options));
	};

	return MusicShop;
})();

// Linko.pl - end of file 'sites/MusicShop.js'
// Linko.pl - start of file 'sites/MySites.js'

Linko.sites.MySites = (function () {
	var cookieName = 'MySites-username';
	var logger = Linko.log.getLogger('Linko.sites.MySites');

	var MySites = function (args) {
		if (!args) {
			args = {};
		}

		this.user                = {};
		this.authorized          = false;
		this.waitingForLoginInfo = false;

		if (typeof args.uploadCallback === 'function') {
			this.uploadCallback = args.uploadCallback;
		}

		var cookie = Linko.util.cookie.get(cookieName);
		if (cookie !== null) {
			logger.info('found cookie: "' + cookie + '"');
			this.authorized = true;
			this.user.name = cookie;
		}
	};

	MySites.logger = logger;

	MySites.prototype.authorize = function (username, password, callbacks, force) {
		if (typeof callbacks === 'function') {
			callbacks = { 'success':callbacks };
		}

		callbacks = Linko.util.extendcb({
			'success': Linko.noop,
			'error': Linko.noop
		}, callbacks);

		if (this.authorized && !force) {
			logger.debug('authorize: authorized already');
			Linko.util.callAll(callbacks.success);
			return;
		}

		if (this.waitingForLoginInfo) {
			return;
		}

		try {
			this.waitingForLoginInfo = true;

			var self = this;
			self.login(username, password, {
				'success': function (obj) {
					self.waitingForLoginInfo = false;
					if (obj.loggedIn) {
						self.authorized = true;
						self.user.name = obj.user;
						Linko.util.cookie.set(cookieName, obj.user);

						logger.info('authorize: success');
						Linko.util.callAll(callbacks.success, arguments);
					}
					else {
						logger.error('login failed', arguments);
						Linko.util.callAll(callbacks.error, arguments);
					}
				},
				'error': function () {
					Linko.util.cookie.remove(cookieName);
					self.waitingForLoginInfo = false;
					logger.error('authorize: login error callback', arguments);
					Linko.util.callAll(callbacks.error, arguments);
				}
			});
		}
		catch (e) {
			Linko.util.cookie.remove(cookieName);
			logger.error('authorize: got an exception', arguments);
			Linko.util.callAll(callbacks.error, arguments);
		}
	};

	MySites.prototype.login = function (username, password, callbacks) {
		if (typeof callbacks === 'function') {
			callbacks = { 'success':callbacks };
		}

		if (!callbacks) {
			callbacks = {};
		}

		if (!username || !password) {
			logger.warn('login: No username or password given');
		}

		var params = {};
		if (username && password) {
			params = { 'username':username, 'password':password };
		}

		return this.request({
			'method'    : 'login',
			'httpMethod': 'POST',
			'params'    : params,
			'callbacks' : Linko.util.extendcb({
				'filter': function (response) {
					var obj = {};
					obj.loggedIn = !!response.match(/var logged_in = true/);

					if (obj.loggedIn) {
						var match = response.match(/write_username = "([^"]*)"/);
						if (match) {
							obj.user = match[1];
						}
					}

					return obj;
				}
			}, callbacks)
		});
	};

	MySites.prototype.logout = function (callback) {
		logger.debug('logout: opening logout window');
		window.open('http://www.mysites.com/logout');
		this.authorized = false;
		this.user = {};
		Linko.util.cookie.remove(cookieName);

		if (callback) {
			callback();
		}
	};

	var renamers = Linko.util.makeRenamers({
		'photo': {
			'.itemType'   : { 'constant':'photo' },
			'.id'         : 'id',
			'.url'        : 'full_item_url',
			'.thumbnail'  : 'thumbnail_image_url',
			'.name'       : 'name',
			'.uploadName' : 'upload_name',
			'.prettyName' : 'pretty_name',
			'.permissions': 'permission',   // onlyme|everyone|???
			'.created'    : { 'path':'added_at'   , 'filter':Date   },
			'.modified'   : { 'path':'modified_at', 'filter':Date   },
			'.width'      : { 'path':'width'      , 'filter':Number },
			'.height'     : { 'path':'height'     , 'filter':Number }
		},
		'audio': {
			'.itemType'   : { 'constant':'audio' },
			'.id'         : 'id',
			'.url'        : 'full_item_url',
			'.thumbnail'  : 'thumbnail_image_url',
			'.name'       : 'name',
			'.uploadName' : 'upload_name',
			'.prettyName' : 'pretty_name',
			'.permissions': 'permission',   // onlyme|everyone|???
			'.created'    : { 'path':'added_at'   , 'filter':Date },
			'.modified'   : { 'path':'modified_at', 'filter':Date }
		},
		'video': {
			'.itemType'   : { 'constant':'video' },
			'.id'         : 'id',
			'.url'        : 'full_item_url',
			'.thumbnail'  : 'thumbnail_image_url',
			'.name'       : 'name',
			'.uploadName' : 'upload_name',
			'.prettyName' : 'pretty_name',
			'.permissions': 'permission',   // onlyme|everyone|???
			'.created'    : { 'path':'added_at'   , 'filter':Date },
			'.modified'   : { 'path':'modified_at', 'filter':Date }
		},
		'folder': {
			'.itemType'   : { 'constant':'folder' },
			'.id'         : 'id',
			'.thumbnail'  : 'thumbnail_image_url',
			'.name'       : 'name',
			'.uploadName' : 'upload_name',
			'.prettyName' : 'pretty_name',
			'.permissions': 'permission',    // onlyme|everyone|???
			'.created'    : { 'path':'added_at'   , 'filter':Date },
			'.modified'   : { 'path':'modified_at', 'filter':Date }
		}
	});

	MySites.prototype.renameModel = function (model) {
		var out = {
			'path' : model && model.path || null,
			'items': []
		};

		(model && model.items || []).forEach(function (item) {
			var mimeType = item && item.content_type || '';

			if (mimeType.match(/^image/)) {
				out.items.push(renamers.photo(item));
			}
			else if (mimeType.match(/^audio/)) {
				out.items.push(renamers.audio(item));
			}
			else if (mimeType.match(/^video/)) {
				out.items.push(renamers.video(item));
			}
			else if (item && item.type === 'folder') {
				if (item.pretty_name !== 'trash') {
					out.items.push(renamers.folder(item));
				}
			}
			else {
				logger.warn('renameModel: unknown item type: "' + item + '"');
			}
		});

		return out;
	};

	MySites.prototype.getRootFolders = function (callbacks) {
		callbacks = Linko.util.extendcb({}, callbacks);

		var self = this;
		return this.request({
			'method': 'model',
			'params': {
				'path': '/',
				'username_override': this.user.name
			},
			'callbacks': Linko.util.extendcb({
				'filter': function (response) {
					var json = String(response).match(/^(?:[^=]*=\s*)?([\s\S]*)/)[1];
					try {
						return self.renameModel(JSON.parse(json));
					}
					catch (e) {
						throw {
							'description': 'JSON.parse failed',
							'response':    response,
							'json':        json,
							'error':       e
						};
					}
				}
			}, callbacks)
		});
	};

	// folder should be the 'prettyName' from MySites
	MySites.prototype.getFolderContents = function (folder, callbacks) {
		callbacks = Linko.util.extendcb({}, callbacks);

		if (!folder || folder === 'trash') {
			logger.error('getMediaItemsFromFolder: invalid folder: "' + folder + '"');
			Linko.util.callAll(callbacks.error);
			return null;
		}

		var self = this;
		return this.request({
			'method': 'model',
			'params': {
				'path': folder,
				'username_override': this.user.name
			},
			'callbacks': Linko.util.extendcb({
				'filter': function (response) {
					var json = String(response).match(/^(?:[^=]*=\s*)?([\s\S]*)/)[1];
					try {
						return self.renameModel(JSON.parse(json));
					}
					catch (e) {
						logger.error('getMediaItemsFromFolder: couldn\'t parse response: "' + json + '"');
						throw 'JSON.parse failed';
					}
				}
			}, callbacks)
		});
	};

	MySites.prototype.request = function (args) {
		args = Linko.util.extend({
			'url': 'http://www.mysites.com/' + Linko.util.getDef('', args, 'method')
		}, args);

		// ugly name clash
		args.method = args.httpMethod || 'GET';

		return Linko.util.ajax(args);
	};

	(function () {
		var MySitesDevice = function (mysites) {
			this.mysites = mysites;
		};

		MySites.prototype.getDeviceObject = function () {
			return new MySitesDevice(this);
		};

		MySitesDevice.prototype.connectionMode = function () {
			return 10;
		};

		MySitesDevice.prototype.getName = function () {
			return 'MySites';
		};

		MySitesDevice.prototype.getId = function () {
			return 'mysites';
		};

		MySitesDevice.prototype.getAttribute = function (attribute) {
			return null;
		};

		MySitesDevice.prototype.downloadFile = function () {
			var self = this;

			var downloadFile = {
				'getTargetDeviceType': function () {
					return 1; // virtual device
				},

				'setName': function (name) {
					downloadFile.name = name;
				},

				'setMimeType': function (mimeType) {
					downloadFile.mimeType = mimeType;
				},

				'open': function (item) {
					logger.info('MySitesDevice.open');
					Linko.util.extend(downloadFile, item);
				},

				'begin': function () {
					logger.info('MySitesDevice.begin');

					if (!downloadFile.name) {
						logger.warn('MySitesDevice.begin: device.name is not set');
					}

					if (!downloadFile.mimeType) {
						logger.warn('MySitesDevice.begin: device.mimeType is not set');
					}

					if (!downloadFile.src) {
						logger.error('MySitesDevice.begin: device.src is not set, aborting');
						return;
					}

					try {
						var params = {
							'album_name': downloadFile.album_name
						};

						var form = new Linko.WebForm();
						form.downloadFile = downloadFile;

						if (!form) {
							throw 'Creating WebForm failed';
						}

						form.open('http://www.mysites.com/upload');
						Linko.util.each(params, function (k, v) {
							form.addField(k, v);
						});
						form.addFile('file', this.src, this.name, this.mimeType);
						form.submit({
							'sourceDeviceId': Linko.deviceManager.computer.getId(),
							'targetDeviceId': self.getId()
						});
					}
					catch (e) {
						logger.error('MySitesDevice.downloadFile.begin: ' + e);
						throw e;
					}
				},

				'end': function (form) {
					logger.info('MySitesDevice.end');
				}
			};

			return downloadFile;
		};
	})();

	return MySites;
})();
// Linko.pl - end of file 'sites/MySites.js'
// Linko.pl - start of file 'sites/PlaylistDB.js'

Linko.sites.PlaylistDB = (function () {
	/**
	 * A wrapper for our playlist database.
	 * @param {Object} [options]
	 * @param {String} [options.apiURL]
	 */
	var PlaylistDB = function (args) {
		this.apiURL = Linko.util.get(args, 'apiURL') || 'http://' + document.location.host + '/application/playlist/api/playlist.php';
		this.queue = [];
		//this.log = [];
	};

	var logger = PlaylistDB.logger = Linko.log.getLogger('Linko.sites.PlaylistDB');
	var cacheKeys = [];
	var bustCache = function () {
		if (cacheKeys.length > 0) {
			logger.debug('Clearing ' + cacheKeys.length + ' items from AJAX cache');
		}
		while (cacheKeys.length > 0) {
			delete Linko.util.ajax.cache[cacheKeys.shift()];
		}
	};

	PlaylistDB.prototype.flush = function () {
		if (this.queue.length === 0) {
			//logger.debug('flush: queue.length = 0, nothing to do');
			return;
		}
		if (this.queue.length === 1) {
			logger.debug('flush: queue.length = 1, sending a normal request');
			return Linko.util.ajax(this.queue.shift());
		};
		logger.debug('flush: queue.length = ' + this.queue.length);
		var batch = JSON.stringify(this.queue.map(function (options) {
			return options.params;
		}));
		var oldQueue = Linko.util.copy(this.queue);
		this.queue = [];
		var failed = [];
		var rawData = null;
		var response = null;
		var self = this;
		//this.log.push('Sending batch request; action = ' + oldQueue.map(function (options) { return options.params.action; }).join(','));
		Linko.util.ajax({
			'cache'    : 0,
			'method'   : 'POST',
			'parseJSON': true,
			'params'   : {
				'__batch__': batch
			},
			'url'      : this.apiURL,
			'callbacks': {
				'filter': function (data) {
					if (!Array.isArray(data) || data.length !== oldQueue.length) {
						throw '';
					}
					rawData = data.map(JSON.stringify);
					oldQueue.forEach(function (options, i) {
						try {
							(Linko.util.get(options, ['callbacks', 'filter']) || []).forEach(function (f) {
								data[i] = f(data[i]);
							});
							failed.push(false);
						}
						catch (e) {
							failed.push(true);
						}
					});
					return data;
				},
				'beforeSuccess': function (data, status, req) {
					logger.debug('Batch beforeSuccess');

					//if (Linko.util.get(req, 'cacheKey')) {
					//	cacheKeys.push(req.cacheKey);
					//}
					oldQueue.forEach(function (options, i) {
						if (!failed[i]) {
							Linko.util.callAll(options.callbacks.beforeSuccess, [data[i], status, req]);
						}
					});
				},
				'success': function (data) {
					logger.debug('Batch success');
					response = data;
					//oldQueue.forEach(function (options, i) {
					//	Linko.util.callAll(options.callbacks.success, [data[i]]);
					//});
				},
				'error': function () {
					logger.debug('Batch error');
					oldQueue.forEach(function (x, i) {
						failed[i] = true;
					});
					oldQueue.forEach(function (options) {
						Linko.util.callAll(options.callbacks.error);
					});
				},
				'finally': function () {
					logger.debug('Batch finally');
					oldQueue.forEach(function (options, i) {
						if (failed[i]) {
							Linko.util.callAll(options.callbacks.error, [Linko.util.get(response, i)]);
						}
						else {
							Linko.util.callAll(options.callbacks.success, [Linko.util.get(response, i)]);
						}
					});
					oldQueue.forEach(function (options) {
						Linko.util.callAll(options.callbacks['finally']);
					});
					oldQueue.forEach(function (options, i) {
						if (!failed[i]) {
							var key = Linko.util.ajax.addToCache(options.cache, options, {
								'status':  200,
								'headers': '',
								'body':    rawData[i]
							});
							if (key) {
								logger.debug('Added sub-request ' + i + ' to ajax cache');
								cacheKeys.push(key);
							}
						}
					});

				}
			}
		});
//		while (this.queue.length > 0) {
//			Linko.util.ajax(this.queue.shift());
//		}
	};

	PlaylistDB.prototype.request = function (options) {
		options = Linko.util.extendcb({
			'method'   : 'POST',
			'parseJSON': true,
			'url'      : this.apiURL,
			'callbacks': {
				'beforeSuccess': function (response, status, req) {
					if (Linko.util.get(req, 'cacheKey')) {
						cacheKeys.push(req.cacheKey);
					}
				}
			}
		}, options);

		if (Linko.util.ajax.lookupCache(options)) {
			//this.log.push('Lookup from AJAX cache; action = ' + options.params.action);
			return Linko.util.ajax(options);
		}

		if (options.async === false || !Linko.util.get(options, ['callbacks', 'success'])) {
			//this.log.push('Synchronous request; action = ' + options.params.action);
			return Linko.util.ajax(options);
		}

		this.queue.push(options);
		var self = this;
		setTimeout(function () {
			self.flush();
		}, 0);
		return null;
	};

	(function () {
		var methods = {
			'store':                 null,
			'storeTemp':             {},
			'remove':                { 'apiMethod':'delete' },
			'addFacebookPostId':     {},
			'getFacebookPostIdsByPlaylist': {},
			'lookup':                { 'cache':1000 },
			'lookupTemp':            { 'cache':true },
			'playlistViewed':        {},
			'getTopPlaylists':       { 'cache':30000, 'apiMethod':'topPlaylists' },
			'getLatestPlaylists':    { 'cache':30000, 'apiMethod':'latestPlaylists' },
			'getPlaylistsByUser':    { 'cache':30000, 'apiMethod':'playlistsByUser' },
			'getDerivedPlaylists':   { 'cache':30000, 'apiMethod':'derivedPlaylists' },
			'getTopTracks':          { 'cache':60000 },
			'getMostLikedTracks':    { 'cache':60000, 'apiMethod':'mostLikedTracks' },
			'getTags':               { 'cache':30000 },
			'getTagsByPlaylist':     { 'cache':30000 },
			'getPage':               { 'cache':1000 },
			'getPlaylistsByTag':     { 'cache':30000, 'apiMethod':'getPlaylistsByTag' },
			'getChannelByPage':      { 'cache':30000 },
			'getPagesByChannel':     { 'cache':1000 },
			'setBlockedPageChannel': {},
			'setPageChannel':        {},
			'storeCartId':           {},
			'getCartId':             {},
			'dropCartId':            {},
			'search':                { 'cache':30000 },
			'createChannel':         {},
			'lookupChannel':         { 'cache':30000 },
			'getChannels':           { 'cache':30000 },
			'getPlaylists':          { 'cache':30000 },
			'getTracks':             { 'cache':30000 },
			'getUsers':              { 'cache':30000 },
			'lookupUser':            { 'cache':30000 },
			'setUserLevel':          {},
			'submitChannelRequest':  {},
			'submitFeedback':        {},
			'incrementViewCount':    {}
		};

		PlaylistDB.ajaxParams = {};

		Linko.util.each(methods, function (method, data) {
			PlaylistDB.ajaxParams[method] = {
				'cache': Linko.util.get(data, 'cache') || null,
				'params': {
					'action': Linko.util.get(data, 'apiMethod') || method
				},
				'callbacks': {}
			};
			if (data !== null) {
				PlaylistDB.prototype[method] = function (options) {
					return this.request(Linko.util.extendcb({}, PlaylistDB.ajaxParams[method], options));
				};
			}
		});
	})();

	PlaylistDB.prototype.store = function (options) {
		return this.request(Linko.util.extendcb({}, PlaylistDB.ajaxParams['store'], {
			'callbacks': {
				'beforeSuccess': function () {
					bustCache();
				}
			}
		}, options));
	};

	PlaylistDB.prototype.remove = function (options) {
		return this.request(Linko.util.extendcb({}, PlaylistDB.ajaxParams['remove'], {
			'callbacks': {
				'beforeSuccess': function () {
					bustCache();
				}
			}
		}, options));
	};

	return PlaylistDB;
})();

// Linko.pl - end of file 'sites/PlaylistDB.js'
// Linko.pl - start of file 'sites/SecuryCast.js'

Linko.sites.SecuryCast = (function () {
	// Some retailerIds:
	//
	//   MTV3:        'F092DC3E-6FBA-B24B-8896-9454FCBAB41A'
	//   Net Anttila: 'ECAB2407-9707-1345-85A3-81A5D870E35F'
	//   Dazzboard:   '69EED238-B26D-0C4F-A82F-9BF85B03C43B'
	//   Nelonen:     '431E68AB-6F9A-B246-8A88-4B0BC9190961'

	var instances = [];
	var logger = Linko.log.getLogger('Linko.sites.SecuryCast');

	var SecuryCast = function (args) {
		args = Linko.util.extend({}, args);

		if (!args.apiURL) {
			logger.error('constructor: no apiURL given');
			return null;
		}

		if (!args.redirectURL) {
			logger.error('constructor: no redirectURL given');
			return null;
		}

		instances.push(this);

		this.apiURL = args.apiURL;
		this.redirectURL = args.redirectURL || '';
		this.waitingForPayment = false;
		this.setRetailerId(args.retailerId || '431E68AB-6F9A-B246-8A88-4B0BC9190961');
	};

	SecuryCast.logger = logger;

	SecuryCast.prototype = new Linko.sites.MusicShop();

	SecuryCast.paymentCallback = function (params) {
		logger.info('paymentCallback', params);
		for (var i = 0; i < instances.length; ++i) {
			if (instances[i].waitingForPayment) {
				instances[i].paymentCallback_(params);
				return;
			}
		}
		logger.warn('paymentCallback: no callback given');
	};

	SecuryCast.prototype.paymentCallback_ = function (params) {
		this.waitingForPayment = false;

		Linko.util.callAll(this.paymentCallback, params);
	};

	SecuryCast.prototype.request = function (args) {
		return Linko.util.ajax(Linko.util.extendcb({
			'method'   : 'POST',
			'parseJSON': true,
			'url'      : this.apiURL + 'catalogue.php',
			'params'   : {
				'shop': SecuryCast.prototype.shop
			},
			'callbacks': {}
		}, args));
	};

	SecuryCast.prototype.setRetailerId = function (retailerId) {
		this.retailerId = retailerId;
		SecuryCast.prototype.shop = 'SecuryCast' + (retailerId ? ':' + retailerId : '');
	};

	SecuryCast.prototype.authorize = function (callbacks) {
		Linko.util.callAll(Linko.util.get(callbacks, 'success'));
		Linko.util.callAll(Linko.util.get(callbacks, 'finally'));
	};

	(function () {
		var methods = {
			'makeCart'          : { 'apiMethod':'newCart' },
			'getCart'           : {},
			'addItemToCart'     : { 'apiMethod':'addItem' },
			'removeItemFromCart': { 'apiMethod':'removeItem' }
		};

		Linko.util.each(methods, function (methodName, data) {
			SecuryCast.prototype[methodName] = function (options) {
				return this.request(Linko.util.extend({
					'params': {
						'action': data.apiMethod || methodName,
						'shop':   this.shop
					},
					'url': this.apiURL + 'purchasing.php'
				}, options));
			};
		});
	})();

	SecuryCast.prototype.newCart = (function () {
		var zeroId = '00000000-0000-0000-0000-000000000000';

		var Cart = function (sc) {
			this.id        = zeroId;
			this.sc        = sc; // the SecuryCast instance
			this.shop      = sc.shop;
			this.items     = [];
			this.price     = {};
			this.stats     = {};
			this.gettingId = false;
			this.waiting   = false;
			this.queue     = [];
			this.fuzzyMap  = {};

			this.es = new Linko.EventSource(['init', 'change']);

			this.updateStats();
		};

		Cart.logger = Linko.log.getLogger('Linko.sites.SecuryCast.Cart');

		Cart.prototype.updateStats = function () {
			this.stats = {
				'albumCount'  : 0,
				'trackCount'  : 0,
				'minPrice'    : Number.POSITIVE_INFINITY,
				'maxPrice'    : Number.NEGATIVE_INFINITY,
				'averagePrice': this.items.length && this.price.amount / this.items.length
			};

			var self = this;
			this.items.forEach(function (item) {
				switch (item.type) {
				case 'album':
					++self.stats.albumCount;
					break;
				case 'track':
					++self.stats.trackCount;
					break;
				default:
					Cart.logger.warn('updateStats: invalid item.type: "' + item.type + '"');
				}

				if (item.price) {
					self.stats.minPrice = Math.min(self.stats.minPrice, item.price.amount);
					self.stats.maxPrice = Math.max(self.stats.maxPrice, item.price.amount);
				}
			});
		};

		Cart.prototype.init = Linko.noop;

		Cart.prototype.request = function (method, options) {
			if (this.id === zeroId && this.gettingId || this.waiting) {
				this.queue.push([method, options]);
				return null;
			}

			var self = this;
			options = Linko.util.extendcb({
				'params': {
					'cartId': this.id
				},
				'callbacks': {
					'beforeSuccess': function (response) {
						if (response && response.cart) {
							response = response.cart;
						}

						if (response && response.id) {
							var changed = self.id !== response.id;
							self.id = response.id;
							if (changed) {
								self.es.dispatchEvent('init', [self.id]);
							}
						}

						if (response && response.shop) {
							self.shop = response.shop;
							self.sc.setRetailerId(response.shop.split(':')[1]);
						}

						if (response && response.price) {
							self.price = response.price;
						}

						if (response && response.items) {
							var eq = Linko.util.eq(self.items, response.items);
							self.items = response.items;
							if (!eq || true) {
								self.updateStats();
								self.es.dispatchEvent('change');
							}
						}
					},
					'finally': function () {
						self.waiting   = false;
						self.gettingId = false;

						if (self.queue.length > 0) {
							var item = self.queue.shift();
							self.request(item[0], item[1]);
						}
					}
				}
			}, options);

			this.gettingId = this.id === zeroId;
			this.waiting   = true;

			if (method === 'removeItemFromCart') {
				var inCart = Linko.util.any(this.items, function (x) {
					return x.id === options.params.cartItemId;
				});
				if (!inCart) {
					Cart.logger.debug('removeItem: item not in cart, skipping "' + options.params.cartItemId + '"');
					Linko.util.callAll(options.callbacks.success);
					Linko.util.callAll(options.callbacks['finally']);
					return null;
				}
			}
			else if (method === 'addItemToCart') {
				var item = JSON.parse(options.params.item);
				var inCart = Linko.util.any(this.items, function (x) {
					return x && x.id === item.id && x.shop && x.shop === item.shop;
				});
				if (inCart) {
					Cart.logger.debug('addItem: item already in cart, skipping');
					Linko.util.callAll(options.callbacks.success, {ok:true,fuzzy:false});
					Linko.util.callAll(options.callbacks['finally']);
					return null;
			        }
			}

			return this.sc[method](options);
		};

		Cart.prototype.fuzzy = function (itemId) {
			return !!this.fuzzyMap[itemId];
		};

		Cart.prototype.addItem = function (item, callbacks) {
			callbacks = Linko.util.extendcb({}, callbacks);

			if (!Array.isArray(item)) {
				item = [item];
			}

			var statuses = [];
			var self = this;
			var go = function (i) {
				if (i >= item.length) {
					Linko.util.callAll(callbacks.success, {statuses:statuses});
					Linko.util.callAll(callbacks['finally']);
					return;
				}

				self.request('addItemToCart', {
					'method': 'POST',
					'params': {
						'item': JSON.stringify(item[i])
					},
					'callbacks': {
						'success': function (data) {
							data = Linko.util.extend({itemId:null, fuzzy:false}, data);
							Cart.logger.debug('addItem: success callback', data);
							self.fuzzyMap[data.itemId] = !!data.fuzzy;
							statuses.push({
								'ok':      true,
								'fuzzy':   !!data.fuzzy,
								'skipped': false
							});
						},
						'error': function (data) {
							Cart.logger.debug('addItem: error callback', data);
							statuses.push({
								'ok':      false,
								'error':   data && data.error,
								'skipped': false
							});
						},
						'finally': function () {
							go(i + 1);
						}
					}
				});
			};

			return go(0);
		};

		Cart.prototype.removeItem = function (itemId, callbacks) {
			return this.request('removeItemFromCart', {
				'callbacks': callbacks,
				'params': {
					'cartItemId': itemId
				}
			});
		};

		Cart.prototype.getContents = function (callbacks) {
			return this.request('getCart', {
				'callbacks': callbacks
			});
		};

		Cart.prototype.loadId = function (cartId, callbacks) {
			return this.request('getCart', {
				'params': {
					'cartId': cartId
				},
				'callbacks': callbacks
			});
		};

		Cart.prototype.purchase = function (fakeLocker, callbacks) {
			if (this.waitingForPayment) {
				Cart.logger.warn('purchase: one payment is pending already');
				return;
			}

			this.sc.waitingForPayment = true;

			callbacks = Linko.util.extendcb({
				'success': function (params) {
					logger.info('purchase success callback', params);
				},

				'error': function () {
					logger.error('purchase error callback', arguments);
				}
			}, callbacks);

			var self = this;
			var done = function (ok, args) {
				done = Linko.noop;
				if (ok) {
					setTimeout(function () {
						self.sc.request({
							'async': true,
							'url':   self.sc.apiURL + 'purchasing.php',
							'params': {
								// TODO
								'user':       Linko.util.get(dazz, 'cache', 'me', 'id'),
								'purchaseId': args.purchaseid,
								'action':     'savePurchaseId'
							}
						});
					}, 0);
					var data = {
						'id':         args.purchaseid,
						'shop':       self.shop,
						'totalItems': self.items.length,
						'items':      self.items.map(function (item) {
							return {
								'album': {
									'id': item.albumId,
									'shop': self.shop
								},
								'lockerTracks': [{
									'track': item,
									'remainingDownloads': 5,
									'downloadURLs': [{
										'url': 'http://content.securycast.com/' + args.purchaseid + '/' + item.id + '/track.mp3',
										'format': {
											'id':         '1',
											'shop':       self.shop,
											'fileFormat': 'mp3'
										}
									}]
								}]
							};
						})
					};
					Linko.util.callAll(callbacks.success, [data]);
				}
				else {
					Linko.util.callAll(callbacks.error);
				}
				Linko.util.callAll(callbacks['finally']);
				self.sc.waitingForPayment = false;
			};

			this.sc.paymentCallback = function (params) {
				done(true, params);
			};

			var shopBase = 'https://www.securycast.com/scweb/scwebshop.dll?viewcart';

			var isNRJ = this.sc.retailerId === '25DBDBCE-B7F7-8E41-9003-BF32F0F3D31C';
			if (isNRJ) {
				shopBase = 'http://kauppa.nrj.fi/ostoskori/kassalle/';
			}

			logger.info('Opening payment window');
			var w = window.open(shopBase + Linko.util.makeQueryString({
				//'dazzplay':       isNRJ ? '1' : undefined,
				'organisationid': this.sc.retailerId,
				'cartid':         this.id,
				'languagecode':   'FI',
				'returnurl':      this.sc.redirectURL
			}, true));

			var waitForClose = setInterval(function () {
				if (!w.closed) {
					return;
				}

				logger.info('Payment window was closed');
				clearInterval(waitForClose);
				done(false, []);
			}, 500);
		};

		return function () {
			return new Cart(this);
		};
	})();

	return SecuryCast;
})();
// Linko.pl - end of file 'sites/SecuryCast.js'
// Linko.pl - start of file 'sites/SevenDigital.js'

Linko.sites.SevenDigital = (function () {
	var cookieName = 'SevenDigital-token';
	var instances = [];
	var logger = Linko.log.getLogger('Linko.sites.SevenDigital');

	var SevenDigital = function (args) {
		args = Linko.util.extend({}, args);

		if (!args.apiURL) {
			logger.error('constructor: no apiURL given');
			return null;
		}

		if (!args.redirectURL) {
			logger.error('constructor: no redirectURL given');
			return null;
		}

		this.apiURL          = args.apiURL || '';
		this.apiOAuthURL     = this.apiURL + 'oauth.php';
		this.redirectURL     = args.redirectURL || '';
		this.cardRedirectURL = args.cardRedirectURL || '';

		this.requestToken = null; // { token:String, secret:String, authorize_url:String }
		this.accessToken  = null; // { token:String, secret:String }
		this.authorized   = false;

		this.waitingForUserAllowance = false;

		this.setCountry(args.country || 'FI');

		instances.push(this);

		var cookie = Linko.util.cookie.get(cookieName);
		if (cookie !== null) {
			logger.info('constructor: found cookie: "' + cookie + '"');

			try {
				this.accessToken = JSON.parse(cookie);
				this.authorized  = true;
			}
			catch (e) {
				logger.warn('constructor: the cookie is not valid JSON');
				Linko.util.cookie.remove(cookieName);
			}
		}
	};

	SevenDigital.logger = logger;

	SevenDigital.prototype = new Linko.sites.MusicShop();

	SevenDigital.prototype.getAccessToken = function () {
		return this.accessToken;
	};

	SevenDigital.prototype.shop = '7Digital';

	SevenDigital.prototype.setCountry = function (country) {
		this.country = country;
		SevenDigital.prototype.shop = '7Digital' + (country ? ':' + country : '');
	};

	SevenDigital.addCardCallback = function (ok) {
		logger.info('addCardCallback', arguments);
		for (var i = 0; i < instances.length; ++i) {
			if (instances[i].waitingForCreditCard) {
				instances[i].addCardCallback_(ok);
				return;
			}
		}
		logger.warn('addCardCallback: no callback given');
	};

	SevenDigital.prototype.addCardCallback_ = function (ok) {
		this.waitingForCreditCard = false;

		Linko.util.callAll(this.addCardCallback, ok);
	};

	SevenDigital.authorizationCallback = function (token) {
		logger.info('authorizationCallback', arguments);
		for (var i = 0; i < instances.length; ++i) {
			if (instances[i].waitingForUserAllowance) {
				instances[i].userAuthorizationCallback_(token);
				return;
			}
		}

		logger.warn('authorizationCallback: got a token (' + token + ') but nobody wants it :(');
	};

	SevenDigital.prototype.userAuthorizationCallback_ = function (token) {
		logger.debug('userAuthorizationCallback_: token = "' + token + '"');

		this.waitingForUserAllowance = false;

		this.authorized = !!token;

		Linko.util.callAll(this.userAllowanceCallback, [token]);
	};

	SevenDigital.prototype.logout = function (callback) {
		window.open('http://us.7digital.com/signout?signout=true&return=%2f');

		Linko.util.cookie.remove(cookieName);
		this.authorized   = false;
		this.requestToken = null;
		this.accessToken  = null;

		Linko.util.callAll(callback);
	};

	SevenDigital.prototype.openAddCardWindow = function (callback) {
		if (!this.accessToken) {
			logger.error('openAddCardWindow: no access token');
			Linko.util.callAll(callback, [false]);
			return;
		}

		logger.info('openAddCardWindow: calling window.open');
		var w = window.open('https://account.7digital.com/dazzboard/payment/addcard' + Linko.util.makeQueryString({
			'oauth_token': this.accessToken.token,
			'returnUrl'  : this.cardRedirectURL + Linko.util.makeQueryString({ 'card':1 }, true)
		}, true));

		var self = this;
		var waitForClose = setInterval(function () {
			if (!w.closed) {
				return;
			}

			logger.info('openAddCardWindow: window was closed');
			clearInterval(waitForClose);
			if (self.waitingForCreditCard) {
				self.addCardCallback_(false);
			}
		}, 500);

		this.waitingForCreditCard = true;
		this.addCardCallback = callback;
	};

	SevenDigital.prototype.authorize = function (callbacks, force) {
		callbacks = Linko.util.extendcb(callbacks);

		if (this.authorized && !force) {
			logger.debug('authorize: authorized already');
			Linko.util.callAll(callbacks.success);
			return;
		}

		try {
			this.requestToken = this.requestRequestToken();
		}
		catch (e) {
			logger.error('authorize: requestRequestToken failed');
			Linko.util.callAll(callbacks.error);
			return;
		}

		var self = this;
		this.requestUserAllowance({
			'success': function () {
				self.requestAccessToken({
					'success': function (accessToken) {
						self.accessToken = accessToken;
						self.authorized  = true;
						Linko.util.cookie.set(cookieName, JSON.stringify(self.accessToken));

						logger.info('authorize: success callback');
						Linko.util.callAll(callbacks.success);
					},
					'error': function () {
						logger.error('authorize: requestAccessToken error callback');
						Linko.util.callAll(callbacks.error, Linko.util.array(arguments));
					}
				});
			},
			'error': function () {
				logger.error('authorize: requestUserAllowance error callback failed');
				Linko.util.callAll(callbacks.error, Linko.util.array(arguments));
			}
		});
	};

	SevenDigital.prototype.makeAuthURL = function () {
		if (!this.requestToken) {
			logger.error('makeAuthURL: no requestToken');
			return null;
		}

		if (!this.redirectURL) {
			logger.error('makeAuthURL: no callback URL');
			return null;
		}

		return this.requestToken.authorize_url + '&oauth_callback=' + encodeURIComponent(this.redirectURL);

	};

	SevenDigital.prototype.requestRequestToken = function (callbacks) {
		return this.request({
			'async': false,
			'params': {
				'action': 'getRequestToken'
			},
			'callbacks': callbacks
		});
	};

	SevenDigital.prototype.requestAccessToken = function (callbacks) {
		if (!this.requestToken) {
			logger.error('requestAccessToken: we don\'t have a request token');
			return null;
		}

		return this.request({
			'params': {
				'action'        : 'getAccessToken',
				'request_token' : this.requestToken.token,
				'request_secret': this.requestToken.secret
			},
			'callbacks': callbacks
		});
	};

	SevenDigital.prototype.requestUserAllowance = function (callbacks) {
		callbacks = Linko.util.extendcb(callbacks);

		this.userAllowanceCallback = callbacks.success;

		logger.info('requestUserAllowance: calling window.open');
		var w = window.open(this.makeAuthURL(), '', 'height=500,width=900');
		var self = this;
		var waitForClose = setInterval(function () {
			if (!w.closed) {
				return;
			}

			clearInterval(waitForClose);
			if (self.waitingForUserAllowance) {
				logger.info('requestUserAllowance: window was closed');
				Linko.util.callAll(self.userAllowanceCallback, false);
			}
		}, 500);
		this.waitingForUserAllowance = true;
	};

	SevenDigital.prototype.getLocker = function (fakeLocker, callbacks) {
		if (!this.authorized) {
			logger.error('getLocker: not authorized');
			return null;
		}

		return this.request({
			'async'    : !!callbacks,
			'callbacks': callbacks,
			'url'      : this.apiURL + 'locker.php',
			'params'   : {
				'action'       : 'getContents',
				'access_token' : this.accessToken.token,
				'access_secret': this.accessToken.secret,
				'fakeLocker'   : fakeLocker ? 1 : 0
			}
		});
	};

	SevenDigital.prototype.request = function (args) {
		return Linko.util.ajax(Linko.util.extend({
			'createXHR': Linko.util.createXHR,
			'method'   : 'POST',
			'parseJSON': true,
			'url'      : this.apiOAuthURL
		}, args));
	};

	(function () {
		var methods = {
			'makeCart'          : { 'apiMethod':'newCart' },
			'getCart'           : {},
			'addItemToCart'     : { 'apiMethod':'addItem' },
			'removeItemFromCart': { 'apiMethod':'removeItem' },
			'purchaseCart'      : { 'apiMethod':'buyCart', 'tokenRequired':true }
		};

		Linko.util.each(methods, function (methodName, data) {
			SevenDigital.prototype[methodName] = function (options) {
				var params = {
					'action': data.apiMethod || methodName
				};
				if (data.tokenRequired) {
					if (!this.accessToken) {
						logger.error(methodName + ': no access token');
						Linko.util.callAll(Linko.util.get(options, 'callbacks', 'error'), 'no access token');
						return null;
					}
					params.access_token  = this.accessToken.token;
					params.access_secret = this.accessToken.secret;
				}

				return this.request(Linko.util.extend({
					'params': params,
					'url': this.apiURL + 'purchasing.php'
				}, options));
			};
		});
	})();

	SevenDigital.prototype.newCart = (function () {
		var Cart = function (sd) {
			this.id       = null;
			this.sd       = sd; // the SevenDigital instance
			this.shop     = sd.shop;
			this.items    = [];
			this.price    = {};
			this.stats    = {};
			this.initing  = false;
			this.queue    = [];
			this.fuzzyMap = {};
			this.waiting  = false;

			this.es = new Linko.EventSource(['init', 'change']);

			this.updateStats();
		};

		Cart.logger = Linko.log.getLogger('Linko.sites.SevenDigital.Cart');

		Cart.prototype.updateStats = function () {
			this.stats = {
				'albumCount'  : 0,
				'trackCount'  : 0,
				'minPrice'    : Number.POSITIVE_INFINITY,
				'maxPrice'    : Number.NEGATIVE_INFINITY,
				'averagePrice': this.items.length && this.price.amount / this.items.length
			};

			var self = this;
			this.items.forEach(function (item) {
				switch (item.type) {
				case 'album':
					++self.stats.albumCount;
					break;
				case 'track':
					++self.stats.trackCount;
					break;
				default:
					Cart.logger.warn('updateStats: invalid item.type: "' + item.type + '"');
				}

				if (item.price) {
					self.stats.minPrice = Math.min(self.stats.minPrice, item.price.amount);
					self.stats.maxPrice = Math.max(self.stats.maxPrice, item.price.amount);
				}
			});
		};

		Cart.prototype.init = function () {
			if (this.id !== null || this.initing) {
				return;
			}

			this.initing = true;

			var self = this;
			this.sd.makeCart({
				'callbacks': {
					'success': function (response) {
						self.id      = response.id;
						self.items   = response.items;
						self.price   = response.price;
						self.initing = false;

						self.es.dispatchEvent('init', [self.id]);

						Cart.logger.info('init: success callback');

						if (self.queue.length > 0) {
							var req = self.queue.shift();
							self.request(req[0], req[1]);
						}
					},
					'error': function (response) {
						Cart.logger.error('init: error callback');
						self.initing = false;
					}
				}
			});
		};

		Cart.prototype.request = function (method, options) {
			if (this.id === null) {
				this.queue.push([method, options]);
				this.init();
				return null;
			}

			if (this.waiting) {
				this.queue.push([method, options]);
				return null;
			}
			this.waiting = true;

			var self = this;
			options = Linko.util.extendcb({
				'params': {
					'cartId': this.id
				},
				'callbacks': {
					'beforeSuccess': function (response) {
						if (response && response.cart) {
							response = response.cart;
						}

						if (response && response.id) {
							var changed = self.id !== response.id;
							self.id = response.id;
							if (changed) {
								self.es.dispatchEvent('init', [self.id]);
							}
						}

						if (response && response.shop) {
							self.shop = response.shop;
						}

						if (response && response.price) {
							self.price = response.price;
						}

						if (response && response.items) {
							var eq = Linko.util.eq(self.items, response.items);
							self.items = response.items;
							if (!eq || true) {
								self.updateStats();
								self.es.dispatchEvent('change');
							}
						}
					},
					'finally': function () {
						self.waiting   = false;
						self.gettingId = false;

						if (self.queue.length > 0) {
							var item = self.queue.shift();
							self.request(item[0], item[1]);
						}
					}
				}
			}, options);

			if (method === 'removeItemFromCart') {
				var inCart = Linko.util.any(this.items, function (x) {
					return x && x.id === options.params.cartItemId;
				});
				if (!inCart) {
					Cart.logger.debug('removeItem: item not in cart, skipping "' + options.params.cartItemId + '"');
					Linko.util.callAll(options.callbacks.success);
					Linko.util.callAll(options.callbacks['finally']);
					return null;
				}
			}
			else if (method === 'addItemToCart') {
				var item = JSON.parse(options.params.item);
				var inCart = Linko.util.any(this.items, function (x) {
					return x && x.trackId === item.id && x.shop && item.shop.match(/^7Digital/);
				});
				if (inCart) {
					Cart.logger.debug('addItem: item already in cart, skipping');
					Linko.util.callAll(options.callbacks.success, {ok:true, fuzzy:false});
					Linko.util.callAll(options.callbacks['finally']);
					return null;
				}
			}

			return this.sd[method](options);
		};

		Cart.prototype.fuzzy = function (itemId) {
			return !!this.fuzzyMap[itemId];
		};

		Cart.prototype.addItem = function (item, callbacks) {
			callbacks = Linko.util.extendcb({}, callbacks);

			if (!Array.isArray(item)) {
				item = [item];
			}

			var statuses = [];
			var self = this;
			var go = function (i) {
				if (i >= item.length) {
					Linko.util.callAll(callbacks.success, {statuses:statuses});
					Linko.util.callAll(callbacks['finally']);
					return;
				}

				self.request('addItemToCart', {
					'method': 'POST',
					'params': {
						'item': JSON.stringify(item[i])
					},
					'callbacks': {
						'success': function (data) {
							data = Linko.util.extend({itemId:null, fuzzy:false}, data);
							Cart.logger.debug('addItem: success callback', data);
							self.fuzzyMap[data.itemId] = !!data.fuzzy;
							statuses.push({
								'ok':      true,
								'fuzzy':   !!data.fuzzy,
								'skipped': false
							});
						},
						'error': function (data) {
							Cart.logger.debug('addItem: error callback', data);
							statuses.push({
								'ok':      false,
								'message': data && data.error,
								'skipped': false
							});
						},
						'finally': function () {
							go(i + 1);
						}
					}
				});
			};

			return go(0);
		};

		Cart.prototype.removeItem = function (itemId, callbacks) {
			return this.request('removeItemFromCart', {
				'callbacks': callbacks,
				'params': {
					'cartItemId': itemId
				}
			});
		};

		Cart.prototype.getContents = function (callbacks) {
			return this.request('getCart', {
				'callbacks': callbacks
			});
		};

		Cart.prototype.loadId = function (cartId, callbacks) {
			var self = this;
			return this.sd.getCart({
				'params': {
					'cartId': cartId
				},
				'callbacks': Linko.util.extendcb({
					'beforeSuccess': function (response) {
						self.id = cartId;
						if (response && response.items) {
							if (!Linko.util.eq(self.items, response.items)) {
								self.es.dispatchEvent('change');
							}
							self.items = response.items;
						}
						if (response && response.price) {
							self.price = response.price;
						}
						self.updateStats();
					}
				}, callbacks)
			});
		};

		Cart.prototype.purchase = function (fakeLocker, callbacks) {
			if (!this.sd.authorized) {
				logger.error('purchase: not authorized');
				return null;
			}

			return this.request('purchaseCart', {
				'callbacks': callbacks,
				'params': {
					'access_token' : this.sd.accessToken.token,
					'access_secret': this.sd.accessToken.secret,
					'fakeLocker'   : +!!fakeLocker
				}
			});
		};

		return function () {
			return new Cart(this);
		};
	})();

	(function () {
		var SevenDigitalDevice = function (sevenDigital) {
			this.sevenDigital = sevenDigital;

			this.db = {
				meta  : null,
				ready : false,
				common: null,
				sync  : {
					firstSync: null,
					canceled : false
				}
			};
		};

		(function () {
			var device = null;
			SevenDigital.prototype.getDeviceObject = function () {
				if (device === null) {
					device = new SevenDigitalDevice(this);
				}
				return device;
			};
		})();

		SevenDigitalDevice.prototype.parseLocker = function (locker, useFakeLocker, callback) {
			var self = this;

			if (!this.db.meta) {
				var uniq = this.sevenDigital.accessToken && this.sevenDigital.accessToken.token || (new Date()).getTime() + Math.random();
				this.db.meta = Linko.deviceManager.computer.getDatabase('locker_' + uniq);
				this.db.ready = false;

				[
					'BEGIN TRANSACTION;',

					// playlist
					'CREATE TABLE IF NOT EXISTS playlist(id TEXT, category INTEGER, fileRef TEXT, pos INTEGER, extend TEXT);',
					'CREATE INDEX IF NOT EXISTS playlist_fileRef ON playlist (fileRef);',

					// audio
					'CREATE TABLE IF NOT EXISTS audio(id PRIMARY KEY, category INTEGER, format TEXT, bitrate INTEGER, duration INTEGER, thumbnailid TEXT, year INTEGER, track INTEGER, genre TEXT, title TEXT, artist TEXT, album TEXT, description TEXT, extend TEXT);',
					'CREATE INDEX IF NOT EXISTS audio_album ON audio (album);',
					'CREATE INDEX IF NOT EXISTS audio_artist ON audio (artist);',
					'CREATE INDEX IF NOT EXISTS audio_title ON audio (title);',

					// files
					'CREATE TABLE IF NOT EXISTS files(id PRIMARY KEY, isFound INTEGER, onStorage INTEGER, onSychdate INTEGER, puid, resource, path, name, ext, size INTEGER, dated INTEGER, metatable, tags);',
					'CREATE INDEX IF NOT EXISTS files_metatable ON files (metatable);',
					'CREATE INDEX IF NOT EXISTS files_name ON files (name);',
					'CREATE TRIGGER IF NOT EXISTS files_deleted AFTER DELETE ON files BEGIN DELETE FROM audio WHERE id=OLD.id; DELETE FROM playlist WHERE id=OLD.id; END;',

					'END TRANSACTION;'
				].forEach(function (val) {
					var res = self.db.meta.execute(val);
					if (res && res.close) {
						res.close();
					}
				});

				var res = this.db.meta.execute('SELECT COUNT(*) FROM files LIMIT 1;');
				var row = res.getRow();
				if (row && row[0] > 0) {
					this.db.ready = true;
				}

				locker = null;
			}

			if (!locker) {
				if (this.db.ready) {
					if (callback) {
						callback();
					}
					return;
				}
				else {
					if (!this.sevenDigital.authorized) {
						this.sevenDigital.authorize(function () {
							self.parseLocker(locker, useFakeLocker, callback);
						});
						return;
					}
					this.sevenDigital.getLocker(!!useFakeLocker, {
						'success': function (data) {
							self.parseLocker(data, useFakeLocker, callback);
						}
					});
					return;
				}
			}

			if (typeof locker === 'string') {
				locker = JSON.parse(locker);
			}

			var lockerTrackToPlaylistId = function (track) {
				if (!track || !track.purchaseDate || !track.purchaseDate.unix) {
					return null;
				}

				var buyDate = new Date(track.purchaseDate.unix * 1000);
				var playlistId = Linko.util.sprintf('virtual.%d.%d.%d %d.%02d', buyDate.getDate(), buyDate.getMonth() + 1, buyDate.getFullYear(), buyDate.getHours(), buyDate.getMinutes())

				return playlistId;
			};

			var addLockerTrack = function (lockerTrack) {
				if (!lockerTrack || !lockerTrack.track) {
					return false;
				}
				var track = lockerTrack.track;

				// files
				var cols = ['id', 'isFound', 'onStorage', 'onSychdate', 'puid', 'resource', 'path', 'name', 'ext', 'size', 'dated', 'metatable', 'tags'];
				var q = self.db.meta.prepare(
					'INSERT OR IGNORE INTO files (' + cols.join(',') + ') ' +
					'VALUES(' + cols.map(function () { return '?'; }).join(',') + ');'
				);

				var now = (new Date()).getTime();
				var dlURL = lockerTrack.downloadURLs[0] || '';
				var dlURLs = JSON.stringify(lockerTrack.downloadURLs || []);
				q.bind([
					track.id,                              // id
					1,                                     // isFound
					0,                                     // onStorage
					now,                                   // onSychdate
					dlURLs,                                // puid
					'',                                    // resource
					dlURL.url,                             // path
					track.title,                           // name
					'',                                    // ext
					0,                                     // size
					lockerTrack.purchaseDate.unix * 1000,  // dated
					'audio',                               // metatable
					''                                     // tags
				]);
				var res = q.execute();
				if (res && res.close) {
					res.close();
				}

				// audio
				var cols = ['id', 'category', 'format', 'bitrate', 'duration', 'thumbnailid', 'year', 'track', 'genre', 'title', 'artist', 'album', 'description', 'extend'];
				var q = self.db.meta.prepare(
					'INSERT OR IGNORE INTO audio (' + cols.join(',') + ') ' +
					'VALUES(' + cols.map(function () { return '?'; }).join(',') + ');'
				);

				q.bind([
					track.id,                              // id
					0,                                     // category
					dlURL.format.fileFormat.toLowerCase(), // format
					dlURL.format.bitrate || 0,             // bitrate
					track.duration * 10000,                // duration
					track.album.img,                       // thumbnailid
					(new Date(track.album.releaseDate.unix * 1000)).getFullYear(),
					                                       // year
					(track.discNumber - 1) * 100 + track.trackNumber,
					                                       // track
					'',                                    // genre
					track.title,                           // title
					track.artist.name,                     // artist
					track.album.title,                     // album
					'',                                    // description
					'' + lockerTrack.remainingDownloads    // extend
				]);
				var res = q.execute();
				if (res && res.close) {
					res.close();
				}

				// playlist
				var playlistId = lockerTrackToPlaylistId(lockerTrack);

				var cols = ['id', 'category', 'fileRef', 'pos', 'extend'];
				var q = self.db.meta.prepare(
					'INSERT OR IGNORE INTO playlist (' + cols.join(',') + ') ' +
					'SELECT ' + cols.map(function () { return '?'; }).join(',')+ ' ' +
					'WHERE NOT EXISTS (SELECT * FROM playlist WHERE playlist.id=? AND playlist.category=10);'
				);

				q.bind([
					playlistId, // id
					10,         // category
					'',         // fileRef
					0,          // pos
					'',         // extend
					playlistId  // WHERE subquery's id
				]);
				var res = q.execute();
				if (res && res.close) {
					res.close();
				}

				// playlist (track entry)
				var cols = ['id', 'category', 'fileRef', 'pos', 'extend'];
				var q = self.db.meta.prepare(
					'INSERT OR IGNORE INTO playlist (' + cols.join(',') + ') ' +
					'SELECT ' + cols.map(function () { return '?'; }).join(',') + ' ' +
					'WHERE NOT EXISTS (SELECT * FROM playlist WHERE playlist.id=? AND playlist.fileRef=? AND playlist.category=0);'
				);

				q.bind([
					playlistId, // id
					0,          // category
					track.id,   // fileRef
					0,          // pos
					'',         // extend
					playlistId, // WHERE subquery's id
					track.id    // WHERE subquery's fileRef
				]);
				var res = q.execute();
				if (res && res.close) {
					res.close();
				}

				return true;
			};

			var mainLoop = function (album_i, track_i) {
				if (!album_i) {
					album_i = 0;
				}
				if (!track_i) {
					track_i = 0;
				}

				for (; album_i < locker.items.length; ++album_i) {
					var lockerAlbum = locker.items[album_i];
					if (!lockerAlbum || !lockerAlbum.lockerTracks) {
						continue;
					}

					self.db.meta.execute('BEGIN TRANSACTION;');
					for (; track_i < lockerAlbum.lockerTracks.length; ++track_i) {
						var lockerTrack = lockerAlbum.lockerTracks[track_i];
						if (!lockerTrack) {
							continue;
						}

						lockerTrack.track.album = lockerAlbum.album;

						addLockerTrack(lockerTrack);
					}
					self.db.meta.execute('END TRANSACTION;');

					setTimeout(function () {
						mainLoop(album_i + 1);
					}, 1);
					return;
				}

				self.db.ready = true;
				if (callback) {
					callback();
				}
			};
			mainLoop();
		};

		SevenDigitalDevice.prototype._openDatabase = function (name, callback) {
			switch (name) {
			case 'METABASE':
				this.parseLocker(null, callback);
				break;
			default:
				break;
			}
		};

		SevenDigitalDevice.prototype.getDatabase = function (name) {
			switch (name) {
			case 'METABASE':
				if (!this.db.meta) {
					this._openDatabase(name);
				}

				if (this.db.meta && this.db.ready) {
					return this.db.meta;
				}

				return null;
				break;
			default:
				return this._openDatabase(name);
				break;
			}
		};

		SevenDigitalDevice.prototype.genericInvoke = function (method, param) {
			return null;
		};

		SevenDigitalDevice.prototype.resetDatabase = function () {
			var result = this.genericInvoke('Database.Reset', '');

			if (result === 1) {
				this.db.ready = false;
				this.db.meta  = null;
				return true;
			}

			return false;
		};

		SevenDigitalDevice.prototype.executeQuery = function (database, query) {
			switch (database) {
			case 'METABASE':
				if (!this.db.meta) {
					this._openDatabase(database);
				}

				if (this.db.meta && this.db.ready) {
					return this.db.meta.execute(query);
				}
				return null;
			}
		};

		SevenDigitalDevice.prototype.getResource = function (id, fmt) {
			if (!this.db.meta || !this.db.ready) {
				return null;
			}

			var q = this.db.meta.prepare('SELECT puid FROM files WHERE id=?1;');
			q.bindParameter(1, id);
			var result = q.execute();

			if (!result.isValidRow()) {
				return null;
			}

			var row = result.getRow();
			result.close();

			try {
				var formats = JSON.parse(row[0]);
				var song = null;

				for (var i = 0; i < formats.length; ++i) {
					if (!song) {
						song = formats[i];
						continue;
					}

					if (song.format.fileFormat.toLowerCase() !== fmt && formats[i].format.fileFormat.toLowerCase() === fmt) {
						song = formats[i];
						continue;
					}

					if (song.format.fileFormat.toLowerCase() === formats[i].format.fileFormat.toLowerCase() && song.format.bitRate < formats[i].format.bitRate) {
						song = formats[i];
					}
				}
			}
			catch (e) {
				return null;
			}

			return song && song.url;
		};

		SevenDigitalDevice.prototype.connectionMode = function () {
			return 10;
		};

		SevenDigitalDevice.prototype.getName = function () {
			return 'Locker';
		};

		SevenDigitalDevice.prototype.getId = function () {
			return 'sevenDigital';
		};

		SevenDigitalDevice.prototype.getAttribute = function (attribute) {
			return '';
		};

		SevenDigitalDevice.prototype.downloadFile = function () {
			var self = this;

			var downloadFile = {
				'getTargetDeviceType': function () {
					return 1; // virtual device
				},

				'setName': function (name) {
					downloadFile.name = name;
				},

				'setMimeType': function (mimeType) {
					downloadFile.mimeType = mimeType;
				},

				'open': function (item) {
					if (item && item.src) {
						downloadFile.src = item.src;
					}
				},

				'begin': Linko.noop,
				'end':   Linko.noop
			};

			return downloadFile;
		};
	})();

	return SevenDigital;
})();
// Linko.pl - end of file 'sites/SevenDigital.js'
// Linko.pl - start of file 'sites/YouTube.js'

Linko.sites.YouTube = (function () {
	var instances = [];
	var cookieName = 'youtube-token';
	var logger = Linko.log.getLogger('Linko.sites.YouTube');

	var YouTube = function (args) {
		if (!args) {
			args = {};
		}

		this.clientId     = args.clientId || null;
		this.developerKey = args.developerKey || null;
		this.redirectURL  = args.redirectURL || null;
		this.uploadRedirectURL
		                  = args.uploadRedirectURL || null;

		this.authToken    = null;
		this.sessionToken = null;
		this.authorized   = false;

		if (typeof args.uploadCallback === 'function') {
			this.uploadCallback = args.uploadCallback;
		}

		this.waitingForAuthToken = false;

		this.userInfo = {
			'name'   : null,
			'picture': null
		};

		instances.push(this);

		var sessionToken = this.getCookie();
		if (!sessionToken) {
			return;
		}

		var self = this;
		this.getTokenInfo(sessionToken, {
			'success': function (tokenInfo) {
				if (self.setSessionToken(sessionToken)) {
					logger.info('found cookie');
				}
				else {
					self.removeCookie();
				}
			},
			'error': function () {
				logger.info('removing invalid cookie');
				self.removeCookie();
			}
		}, { 'async':false });
	};

	YouTube.logger = logger;

	YouTube.authorizationCallback = function (token) {
		logger.info('authorizationCallback: got a token: "' + token + '"');
		var yay = false;
		instances.forEach(function (instance) {
			if (instance.waitingForAuthToken) {
				instance.authTokenCallback_.call(instance, token);
				yay = true;
			}
		});
		if (!yay) {
			logger.error('authorizationCallback: nobody wants my token :(');
		}
	};

	YouTube.prototype.formats = [
		{ 'fmt':  5, 'name':      '240p', 'type':  'FLV', 'resolution': { w: 400, h: 226 }, 'ratio': '16:9' },
		{ 'fmt': 34, 'name':      '360p', 'type':  'FLV', 'resolution': { w: 640, h: 360 }, 'ratio': '16:9' },
		{ 'fmt': 35, 'name':      '480p', 'type':  'FLV', 'resolution': { w: 854, h: 480 }, 'ratio': '16:9' },
		{ 'fmt': 22, 'name':      '720p', 'type':  'MP4', 'resolution': { w:1280, h: 720 }, 'ratio': '16:9' },
		{ 'fmt': 37, 'name':     '1080p', 'type':  'MP4', 'resolution': { w:1920, h:1080 }, 'ratio': '16:9' },
		{ 'fmt': 38, 'name':     '2304p', 'type':  'MP4', 'resolution': { w:4096, h:2304 }, 'ratio': '16:9' },
		{ 'fmt': 18, 'name':    'Medium', 'type':  'MP4', 'resolution': { w: 480, h: 360 }, 'ratio':  '4:3' },
		{ 'fmt': 43, 'name': 'WebM 480p', 'type': 'WebM', 'resolution': { w: 854, h: 480 }, 'ratio': '16:9' },
		{ 'fmt': 45, 'name': 'WebM 720p', 'type': 'WebM', 'resolution': { w:1280, h: 720 }, 'ratio': '16:9' },
		{ 'fmt': 17, 'name':    'Mobile', 'type':  '3GP', 'resolution': { w: 176, h: 144 }, 'ratio': '11:9' }
	];

	YouTube.prototype.getFormatByKey = function (fmt) {
		fmt = Number(fmt);
		return Linko.util.find(this.formats, function (format) {
			if (format.fmt === fmt) {
				return format;
			}
		}) || null;
	};

	YouTube.prototype.getCookie = function () {
		return Linko.util.cookie.get(cookieName);
	};

	YouTube.prototype.setCookie = function (sessionToken) {
		if (sessionToken === this.getCookie()) {
			return;
		}

		logger.trace('setCookie: "' + sessionToken + '"');
		Linko.util.cookie.set(cookieName, sessionToken || this.sessionToken);
	};

	YouTube.prototype.removeCookie = function () {
		Linko.util.cookie.remove(cookieName);
	};

	YouTube.prototype.setSessionToken = function (arg) {
		var token = arg;
		if (arg && arg.token) {
			token = arg.token;
		}

		if (token) {
			this.sessionToken = token;
			this.authorized   = true;
			this.setCookie(token);
			return true;
		}

		logger.error('setSessionToken: no token given');

		return false;
	};

	YouTube.prototype.authTokenCallback_ = function (token) {
		logger.info('authTokenCallback_: "' + token + '"');

		this.authToken           = token;
		this.waitingForAuthToken = false;

		Linko.util.callAll(this.authTokenCallback, token);
	};

	YouTube.prototype.uploadCallback = function (id) {
		logger.info('uploadCallback: "' + id + '"');
	};

	(function () {
		var YouTubeDevice = function (yt) {
			this.yt = yt;
		};

		YouTube.prototype.getDeviceObject = function () {
			return new YouTubeDevice(this);
		};

		YouTubeDevice.prototype.connectionMode = function () {
			return 10;
		};

		YouTubeDevice.prototype.getName = function () {
			return 'YouTube';
		};

		YouTubeDevice.prototype.getId = function () {
			return 'youtube';
		};

		YouTubeDevice.prototype.getAttribute = function (attribute) {
			return {
				'user_info': this.userInfo,
				'session'  : this.sessionToken,
				'category' : 10
			}[attribute];
		};

		YouTubeDevice.prototype.downloadFile = function () {
			var self = this;

			// Instead of returning the object literal directly, it is named
			// to make self-referencing easy.
			var downloadFile = {
				's_token'   : self.yt.sessionToken,
//				'cid'       : self.yt.clientId,
//				'devkey'    : self.yt.developerKey,
//				'next_url'  : self.yt.uploadRedirectURL,
				'src'       : undefined,
				'dest'      : undefined,
				'name'      : undefined,
				'mimeType'  : undefined,
				'onProgress': undefined,
				'metaupload': undefined,
				'fileupload': undefined,

				'getTargetDeviceType': function () {
					return 1; // virtual device
				},

				'setName': function (name) {
					downloadFile.name = name;
				},

				'setMimeType': function (mime) {
					downloadFile.mimeType = mime;
				},

				'open': function (item) {
					logger.info('YouTubeDevice.open');
					if (item.src) {
						downloadFile.src = item.src;
					}
				},

				'begin': function (async) {
					logger.info('YouTubeDevice.begin');

					if (!downloadFile.name) {
						logger.warn('YouTubeDevice.begin: no name');
					}

					if (!downloadFile.mimeType) {
						logger.warn('YouTubeDevice.begin: no mimeType');
					}

					if (!downloadFile.src) {
						logger.error('YouTubeDevice.begin: no src, aborting');
						return;
					}

					// http://code.google.com/apis/youtube/2.0/developers_guide_protocol_browser_based_uploading.html

					// Step 1 - Upload video metadata

					var title       = downloadFile.name;
					var description = 'Video uploaded with Dazzboard';
					var category    = 'Nonprofit';
					var keywords    = 'Dazzboard';

					var xml = '<?xml version="1.0"?>\n' +
					          '<entry xmlns="http://www.w3.org/2005/Atom"\n' +
					          '  xmlns:media="http://search.yahoo.com/mrss/"\n' +
					          '  xmlns:yt="http://gdata.youtube.com/schemas/2007">\n' +
					          '  <media:group>\n' +
					          '    <media:title type="plain">' + title + '</media:title>\n' +
					          '    <media:description type="plain">\n' + description +
					          '    </media:description>\n' +
					          '    <media:category\n' +
					          '      scheme="http://gdata.youtube.com/schemas/2007/categories.cat">' + category + '\n' +
					          '    </media:category>\n' +
					          '    <media:keywords>' + keywords + '</media:keywords>\n' +
					          '  </media:group>\n' +
					          '</entry>';

					self.yt.request({
						'url'    : 'http://gdata.youtube.com/action/GetUploadToken',
						'method' : 'POST',
						'body'   : xml,
						'headers': {
							'Content-Type': 'application/atom+xml; charset=UTF-8'
						},
						'callbacks': {
							'filter': function (response) {
								// Step 2 - Extracting values from the API response

								return {
									'url': response.match(/<url>(.+?)<\/url>/)[1],
									'token': response.match(/<token>(.+?)<\/token>/)[1]
								};
							},
							'success': function (obj) {
								// Step 3 - Uploading the video file
								var form = new Linko.WebForm();

								if (!form) {
									logger.error('downloadFile.begin: Failed to create WebForm');
									return;
								}

								form.downloadFile = downloadFile;

								// Since this isn't a real form, 'nexturl' can be anything
								var uploadURL = obj.url + Linko.util.makeQueryString({
									'nexturl': self.yt.uploadRedirectURL
								}, true);

								form.open(uploadURL);
								form.addField('token', obj.token);
								form.addFile('file', downloadFile.src, downloadFile.name, downloadFile.mimeType);
								form.submit({
									'sourceDeviceId': Linko.deviceManager.computer.getId(),
									'targetDeviceId': self.getId()
								});
							},
							'error': function () {
								logger.error('downloadFile.begin: error callback');
							}
						}
					});
				},

				'end': function (form) {
					logger.info('YouTubeDevice.end');

					var response = form.getResponseText();
					var json = null;
					try {
						json = JSON.parse(response);
					}
					catch (e) {
						logger.error('YouTubeDevice.end: Response is not valid JSON "' + response + '"');
						self.yt.uploadCallback(false, downloadFile.mediaType);
						return;
					}

					if (json && json.ok && json.id) {
						self.yt.uploadCallback(true, downloadFile.mediaType, json.id);
					}
					else {
						logger.error('YouTubeDevice.end: error', json);
						self.yt.uploadCallback(false, downloadFile.mediaType, null);
					}
				}
			};

			return downloadFile;
		};
	})();

	YouTube.prototype.isAuthorized = function (callbacks) {
		return this.getTokenInfo(null, callbacks);
	};

	YouTube.prototype.authorize = function (callbacks, force) {
		callbacks = Linko.util.extendcb({}, callbacks);

		if (this.authorized && !force) {
			logger.debug('authorize: authorized already');
			Linko.util.callAll(callbacks.success);
			return;
		}

		var self = this;
		this.requestAuthToken({
			'success': function (token) {
				self.requestSessionToken(Linko.util.extendcb({
					'success': function () {
						logger.info('authorize: success callback');
					},
					'error': function () {
						logger.error('authorize: requestSessionToken failed');
					}
				}, callbacks));
			},
			'error': function () {
				logger.error('authorize: requestAuthToken failed');
			}
		});
	};

	YouTube.prototype.requestAuthToken = function (callbacks) {
		callbacks = Linko.util.extendcb(callbacks);

		if (!this.redirectURL) {
			logger.error('requestAuthToken: this.redirectURL needs to be set');
			Linko.util.callAll(callbacks.error);
			return;
		}

		this.authTokenCallback = function (token) {
			if (token) {
				Linko.util.callAll(callbacks.success, [token]);
			}
			else {
				Linko.util.callAll(callbacks.error);
			}
		};

		var queryString = Linko.util.makeQueryString({
			'next'   : this.redirectURL,
			'scope'  : 'http://gdata.youtube.com',
			'secure' : '0',
			'session': '1'
		}, true);

		var w = window.open('https://www.google.com/accounts/AuthSubRequest' + queryString, '', 'height=500,width=900');
		var self = this;
		var waitForClose = setInterval(function () {
			if (!w.closed) {
				return;
			}

			clearInterval(waitForClose);
			if (self.waitingForAuthToken) {
				self.authTokenCallback_(false);
			}
		}, 500);
		this.waitingForAuthToken = true;
	};

	YouTube.prototype.requestSessionToken = function (callbacks, authToken) {
		if (!authToken && this.authToken) {
			authToken = this.authToken;
		}

		if (!authToken) {
			logger.error('requestSessionToken: no authToken');
			Linko.util.callAll(callbacks && callbacks.error);
			return false;
		}

		var self = this;
		return this.request({
			'url'      : 'https://www.google.com/accounts/AuthSubSessionToken',
			'params'   : {
				'token': authToken
			},
			'token'    : authToken,
			'callbacks': Linko.util.extendcb({
				'filter': function (str) {
					var out = {};

					// for debugging
					out.response = str;

					var matches = str.match(/^\w+=.*$/gm);

					if (matches) {
						for (var i = 0; i < matches.length; ++i) {
							var match = matches[i].match(/^(\w+)=(.*)$/m);
							if (match) {
								out[match[1].toLowerCase()] = match[2];
							}
						}
					}

					if (typeof out.token === 'undefined') {
						logger.error('requestSessionToken: can\'t find token in response');
					}

					return out;
				},
				'beforeSuccess': function (arg) {
					logger.debug('requestSessionToken: beforeSuccess');
					self.setSessionToken(arg);
				}
			}, callbacks)
		});
	};

	YouTube.prototype.revokeSessionToken = function (token, callbacks) {
		if (!token && this.sessionToken) {
			token = this.sessionToken;
		}

		callbacks = Linko.util.extend({}, callbacks);

		if (!token) {
			logger.warn('revokeSessionToken: there is no token');
			Linko.util.callAll(callbacks.error);
			return null;
		}

		var self = this;
		return this.request({
			'url'      : 'https://www.google.com/accounts/AuthSubRevokeToken',
			'token'    : token,
			'callbacks': Linko.util.extendcb({
				'beforeSuccess': function () {
					self.authorized   = false;
					self.sessionToken = null;
				}
			}, callbacks)
		});
	};

	// Defaults to sessionToken.
	// Note that passing an authToken to this method invalidates it.
	YouTube.prototype.getTokenInfo = function (token, callbacks, options) {
		if (!token && this.sessionToken) {
			token = this.sessionToken;
		}

		if (!token) {
			logger.error('getTokenInfo: There is no token');
			Linko.util.callAll(callbacks.error);
			return null;
		}

		return this.request(Linko.util.extendcb({
			'url'      : 'https://www.google.com/accounts/AuthSubTokenInfo',
			'token'    : token,
			'callbacks': {
				'filter': function (str) {
					var out = {};

					out.response = str;

					var matches = str.match(/^\w+=.*$/gm);
					if (!matches) {
						return out;
					}

					for (var i = 0; i < matches.length; ++i) {
						var match = matches[i].match(/^(\w+)=(.*)$/m);
						if (match) {
							out[match[1].toLowerCase()] = match[2];
						}
					}

					return out;
				}
			}
		}, options));
	};

	YouTube.prototype.getUserInfo = function (callbacks) {
		return this.request({
			'url'      : 'http://gdata.youtube.com/feeds/api/users/default',
			'parseJSON': true,
			'callbacks': Linko.util.extendcb({
				'filter': function (feed) {
					return renamers.user(feed);
				}
			}, callbacks)
		});
	};

	// TODO: The different feeds are copypasta
	var renamers = {};
	renamers = Linko.util.makeRenamers({
		'author': {
			'.name': ['name', '$t'],
			'.url' : ['uri' , '$t']
		},
		'mediaContent': {
			'.url': 'url',
			'.embeddable': {
				'path'  : 'yt$format',
				'filter': function (f) { return Number(f) === 5; }
			},
			'.kind'    : 'expression',
			'.duration': 'duration',
			'.mimeType': 'type'
		},
		'thumbnail': {
			'.width' : { 'path': 'width' , 'filter': Number },
			'.height': { 'path': 'height', 'filter': Number },
			'.url'   : 'url',
			'.time'  : {
				'path'  : 'time',
				'filter': function (str) {
					var parts = str.split(':');
					var seconds = 0;
					for (var i = 0; i < parts.length; ++i) {
						seconds *= 60;
						seconds += Number(parts[i]);
					}
					return seconds;
				}
			}
		},
		'media': {
			'.embed': {
				'path'  : 'media$content',
				'filter': function (contents) {
					var temp = contents.map(function (json) {
						try {
							var out = renamers.mediaContent(json);
							return out && out.embeddable ? out : null;
						}
						catch (e) {
							return null;
						}
					});
					if (temp.length === 0) {
						throw 'not ok';
					}
					return temp[0];
				}
			},
			'.thumbnails': {
				'path'  : 'media$thumbnail',
				'filter': function (thumbnails) {
					return (thumbnails || []).map(function (json) {
						var out = renamers.thumbnail(json);
						return out && out.url ? out : null;
					});
				}
			}
		},
		'video': {
			'.authors': {
				'path'  : 'author',
				'filter': function (authors) {
					return authors.map(renamers.author);
				}
			},
			'.title'        : ['title', '$t'],
			'.description'  : ['media$group', 'media$description', '$t'],
			'.favoriteCount': { 'path': ['yt$statistics', 'favoriteCount'], 'filter': Number },
			'.viewCount'    : { 'path': ['yt$statistics', 'viewCount'    ], 'filter': Number },
			'.published'    : {
				'path'  : ['published', '$t'],
				'filter': Linko.util.date.parse
			},
			'.videoId'      : {
				'path'  : [],
				'filter': function (json) {
					if (typeof json.yt$videoid !== 'undefined') {
						return json.yt$videoid.$t;
					}

					if (typeof json.id !== 'undefined') {
						var id = json.id.$t;
						var prefix = 'http://gdata.youtube.com/feeds/api/videos/';
						if (id.substr(0, prefix.length) === prefix) {
							return id.substr(prefix.length);
						}

						var m = id.match(/\d+:video:([^:,]+)/);
						if (m !== null) {
							return m[1];
						}
					}

					throw 'not ok';
				}
			},
			'.media': {
				'path'  : 'media$group',
				'filter': function (json) { return renamers.media(json); }
			}
		},
		'videoFeed': {
			'.pageSize': {
				'path'  : ['openSearch$itemsPerPage', '$t'],
				'filter': Number
			},
			'.page'    : {
				'path'  : [],
				'filter': function (json) {
					// TODO: figure out a clean way to deal with dependencies
					var pageSize = Number(json.openSearch$itemsPerPage.$t);
					return Math.round((Number(json.openSearch$startIndex.$t) - 1) / pageSize) + 1;
				}
			},
			'.totalItems': {
				'path'  : ['openSearch$totalResults', '$t'],
				'filter': Number
			},
			'.items'     : {
				'path'  : 'entry',
				'filter': function (entries) {
					return entries.map(renamers.video);
				}
			}
		},
		'comment': {
			'.id'       : ['id', '$t'],
			'.title'    : ['title', '$t'],
	 		'.content'  : ['content', '$t'],
			'.authors'  : {
				'path'  : 'author',
				'filter': function (authors) {
					return authors.map(renamers.author);
				}
			},
			'.published': {
				'path'  : ['published', '$t'],
				'filter': Linko.util.date.parse
			},
			'.updated'  : {
				'path'  : ['updated', '$t'],
				'filter': Linko.util.date.parse
			}
		},
		'commentFeed': {
			'.pageSize': {
				'path'  : ['openSearch$itemsPerPage', '$t'],
				'filter': Number
			},
			'.page'    : {
				'path'  : [],
				'filter': function (json) {
					// TODO: figure out a clean way to deal with dependencies
					var pageSize = Number(json.openSearch$itemsPerPage.$t);
					return Math.round((Number(json.openSearch$startIndex.$t) - 1) / pageSize) + 1;
				}
			},
			'.totalItems': {
				'path'  : ['openSearch$totalResults', '$t'],
				'filter': Number
			},
			'.items'     : {
				'path'  : 'entry',
				'filter': function (entries) {
					return entries.map(renamers.comment);
				}
			}
		},
		'user': {
			'.username': ['entry', 'yt$username', '$t'],
			'.image'   : ['entry', 'media$thumbnail', 'url']
		}
	});

	var feedMethods = {
		'topRated'     : 'top_rated',
		'topFavorites' : 'top_favorites',
		'mostViewed'   : 'most_viewed',
		'popular'      : 'most_popular',
		'recent'       : 'most_recent',
		'mostDiscussed': 'most_discussed',
		'featured'     : 'recently_featured',
		'mobile'       : 'watch_on_mobile'
	};

	YouTube.prototype.getFeed = function (feed, params, callbacks) {
		if (!feed) {
			feed = 'topRated';
		}

		var method = feedMethods[feed];

		if (!method) {
			logger.error('getFeed: invalid feed: "' + feed + '"');
			Linko.util.callAll(callbacks && callbacks.error);
			return null;
		}

		return this.request({
			'cache':     60000,
			'url':       'http://gdata.youtube.com/feeds/api/standardfeeds/' + method,
			'params':    params,
			'parseJSON': true,
			'callbacks': Linko.util.extendcb({
				'filter': function (json) {
					return renamers.videoFeed(json.feed);
				}
			}, callbacks)
		});
	};

	YouTube.prototype.related = function (videoId, params, callbacks) {
		if (!videoId) {
			logger.error('related: no videoId given');
			Linko.util.callAll(callbacks && callbacks.error);
			return null;
		}

		return this.request({
			'cache':     60000,
			'url':       'http://gdata.youtube.com/feeds/api/videos/' + videoId + '/related',
			'params':    params,
			'parseJSON': true,
			'callbacks': Linko.util.extendcb({
				'filter': function (json) {
					return renamers.videoFeed(json.feed);
				}
			}, callbacks)
		});
	};

	YouTube.prototype.comments = function (videoId, params, callbacks) {
		if (!videoId) {
			logger.error('comments: no videoId given');
			Linko.util.callAll(callbacks && callbacks.error);
			return null;
		}

		return this.request({
			'cache':     30000,
			'url':       'http://gdata.youtube.com/feeds/api/videos/' + videoId + '/comments',
			'params':    params,
			'parseJSON': true,
			'callbacks': Linko.util.extendcb({
				'filter': function (json) {
					return renamers.commentFeed(json.feed);
				}
			}, callbacks)
		});
	};

	YouTube.prototype.parseWatchPage = function (html, id) {
		// Get ID

		if (!id) {
			//<link rel="canonical" href="/watch?v=hwD3muDefmY">
			var m = /<link\s+rel="canonical"\s+href="\/watch\?v=([^"]*)">/.exec(html);
			if (!m) {
				logger.warn('parseWatchPage: can\'t find video id');
				m = ['video', 'video'];
			}
			id = m[1];
		}

		// Get title

		var m = /<meta\s+name="title"\s+content="([^"]+)">/.exec(html);
		if (!m) {
			logger.warn('parseWatchPage: can\'t find title meta-tag');
			m = ['YouTube ' + id, 'YouTube ' + id];
		}
		var title = Linko.util.htmlDecode(Linko.util.htmlDecode(m[1]));

		// Get download URLs

		var m = /<param\s+name=\\?['"]?flashvars\\?['"]?\s+value=\\?['"]?([^>]*)\\?['"]?>/i.exec(html);
		if (!m) {
			logger.warn('parseWatchPage: can\'t find flashvars');
			return null;
		}
		var flashvars = m[1];
		if (/&amp;fmt_url_map=/.test(flashvars)) {
			flashvars = Linko.util.htmlDecode(flashvars);
		}

		logger.debug('getDownloadURLs: flashvars.length =', flashvars.length);
		var params = Linko.util.parseQueryString(flashvars);
		logger.debug('getDownloadURLs: params =', params);
		if (!params.fmt_stream_map) {
			// TODO: Try fmt_url_map, it seems similar
			logger.warn('parseWatchPage: params doesn\'t have property fmt_stream_map');
			return null;
		}
		var strs = params.fmt_stream_map.split(',');
		var urls = {};
		strs.forEach(function (str) {
			var m = /^(\d+)\|(.+)\|\|.+$/.exec(str);
			if (!m) {
				logger.warn('parseWatchPage: can\'t parse an element of fmt_stream_map:', str);
				return;
			}
			urls[m[1]] = m[2];
			logger.debug('parseWatchPage: URL for format ' + m[1] + ': ' + m[2]);
		});

		// Done

		return {
			'id':    id,
			'title': title,
			'urls':  urls
		};
	};

	YouTube.prototype.getVideoInfo = function (videoID, options) {
		var m = /\?v=(.+)$/.exec(videoID);
		if (m) {
			videoID = m[1];
		}
		var watchURL = 'http://www.youtube-nocookie.com/watch?v=' + encodeURIComponent(videoID);
		logger.debug('getDownloadURLs: watchURL =', watchURL);
		var self = this;
		Linko.util.ajax(Linko.util.extendcb({
			'url': watchURL,
			//'headers': {
			//	'Cookie': ''
			//},
			'callbacks': {
				'filter': function (html) {
					return self.parseWatchPage(html) || Linko.throw_('parse failed');
				},
				'error': function () {
					logger.warn('getVideoInfo: error fetching watchURL');
				}
			}
		}, options));
	};

	YouTube.prototype.getDownloadURL = function (videoId, args) {
		logger.error('getDownloadURL: this is deprecated, use getVideoInfo instead');
		return null;
		/*
		if (!args) {
			args = {};
		}

		try {
			var videoInfo = this.getVideoInfo(videoId);
		}
		catch (e) {
			return null;
		}

		var obj = {};
		var fmt = null;
		var deviceTypeAccepts = {
			'computer':        ['mp4', 'flv', '3gp'],
			'high-end mobile': ['mp4', '3gp', 'flv'],
			'low-end mobile':  ['3gp', 'mp4', 'flv'],
			'mass storage':    ['mp4', 'flv', '3gp']
		};

		var formatCodes = {
			'flv':  [35, 34, 5],
			'mp4':  [38, 37, 22, 18],
			'3gp':  [17],
			'webm': [45, 43]
		};

		var formatOrder = [];
		switch (args.category) {
		case 1:
			if (args.deviceId === Linko.deviceManager.computer.getId()) {
				formatOrder = deviceTypeAccepts['computer'];
			}
			else {
				formatOrder = deviceTypeAccepts['mass storage'];
			}
			break;
		case 3:
		case 4:
			formatOrder = deviceTypeAccepts[(args.screenHeight >= 240 ? 'high' : 'low') + '-end mobile'];
			break;
		default:
			formatOrder = deviceTypeAccepts['mass storage'];
			break;
		}

		Linko.util.each(formatOrder, function (i, val) {
			Linko.util.each(formatCodes[val], function (j, formatCode) {
				Linko.util.each(videoInfo.formats, function (k, videoFormat) {
					if (videoFormat.fmt === formatCode) {
						fmt = videoFormat;
						return false;
					}
					return true;
				});
				return !fmt;
			});
			return !fmt;
		});

		logger.info('YouTube download format:', fmt);
		if (!fmt) {
			return null;
		}

		var fname = videoInfo.title + '.' + fmt.type.toLowerCase();

		obj.name = fname.replace(/\.\.\/|[\\:*?"<>\/|]/g, '');
		obj.src = this.makeDownloadURL(videoInfo, fmt.fmt);

		return obj;
		*/
	};

	YouTube.prototype.search = function (q, params, callbacks) {
		if (!params) {
			params = {};
		}

		params.q = q || '';

		return this.request({
			'cache':     60000,
			'url':       'http://gdata.youtube.com/feeds/api/videos',
			'params':    params,
			'parseJSON': true,
			'callbacks': Linko.util.extendcb({
				'filter': function (json) {
					return renamers.videoFeed(json.feed);
				}
			}, callbacks)
		});
	};

	YouTube.prototype.getMyVideos = function (params, callbacks) {
		return this.request({
			'cache':     15000,
			'url':       'http://gdata.youtube.com/feeds/api/users/default/uploads',
			'params':    params,
			'parseJSON': true,
			'callbacks': Linko.util.extendcb({
				'filter': function (json) {
					return renamers.videoFeed(json.feed);
				}
			}, callbacks)
		});
	};

	YouTube.prototype.like = function (videoId, like, params, callbacks) {
		if (typeof like === 'undefined') {
			like = true;
		}

		var atom = '<?xml version="1.0" encoding="UTF-8"?>\n' +
		           '<entry xmlns="http://www.w3.org/2005/Atom"\n' +
		           '       xmlns:gd="http://schemas.google.com/g/2005">\n' +
		           '  <gd:rating value="' + (like ? 5 : 1) + '" min="1" max="5"/>\n' +
		           '</entry>';

		// TODO: This API call is deprecated but the new one isn't implemented yet.
		//       http://code.google.com/apis/youtube/2.0/developers_guide_protocol_ratings.html
		return this.request({
			'url':       'http://gdata.youtube.com/feeds/api/videos/' + videoId + '/ratings',
			'method':    'POST',
			'headers':   {
				'Content-Type': 'application/atom+xml'
			},
			'body':      atom,
			'params':    params,
			'callbacks': callbacks
		});
	};

	YouTube.prototype.lookup = function (videoId, callbacks) {
		return this.request({
			'cache':     60000,
			'url':       'http://gdata.youtube.com/feeds/api/videos/' + videoId,
			'params':    {},
			'parseJSON': true,
			'callbacks': Linko.util.extendcb({
				'filter': function (json) {
					return renamers.video(json.entry);
				}
			}, callbacks)
		});
	};

	YouTube.prototype.addComment = function (videoId, comment, params, callbacks) {
		var atom = '<?xml version="1.0" encoding="UTF-8"?>\n' +
		           '<entry xmlns="http://www.w3.org/2005/Atom"\n' +
		           '       xmlns:gd="http://gdata.youtube.com/schemas/2007">\n' +
		           '  <content>' + comment + '</content>\n' +
		           '</entry>';

		return this.request({
			'url':       'http://gdata.youtube.com/feeds/api/videos/' + videoId + '/comments',
			'method':    'POST',
			'headers':   {
				'Content-Type': 'application/atom+xml'
			},
			'body':      atom,
			'params':    params,
			'callbacks': callbacks
		});
	};

	var removeParens = function (str) {
		str = String(str);
		str = str.replace(/\([^()]*\)\s*$/, '');
		str = str.replace(/\[[^\[\]]*\]\s*$/, '');
		return str;
	};

	YouTube.prototype.findSong = (function () {
		var cache = {};

		return function (track, callbacks) {
			if (!track) {
				logger.warn('findSong: no track given');
				return null;
			}

			var title  = track.title || '';
			var artist = track.artist && (typeof track.artist === 'string' ? track.artist : track.artist.name) || '';
			var album  = ''; //track.album  && (typeof track.album  === 'string' ? track.album  : track.album.title) || '';

			title  = removeParens(title);
			artist = removeParens(artist);
			album  = removeParens(album);

			var query = [title, artist, album].join(' ');

			var cb = function (video) {
				logger.info('findSong: response', video);
			};
			if (typeof callbacks === 'function') {
				cb = callbacks;
			}
			else if (callbacks && callbacks.success) {
				cb = callbacks.success;
			}

			if (query.match(/\S/g).length < 5) {
				return [];
			}

			if (cache[query]) {
				return void cb(Linko.util.copy(cache[query]));
			}

			try {
				return this.request({
					'url': 'http://gdata.youtube.com/feeds/api/videos',
					'parseJSON': true,
					'params': {
						'pageSize': 10,
						'orderby':  'relevance',
						'category': 'Music',
						'q':        query
					},
					'callbacks': {
						'filter': function (json) {
							var items = renamers.videoFeed(json.feed).items;
							items = items.filter(function (item) {
								return item && item.media && item.media.embed && item.media.embed.url && item.media.embed.embeddable;
							});
							//items = items.sort(function (v1, v2) {
							//	return v2.viewCount - v1.viewCount;
							//});
							var out = items.slice(0,5);
							cache[query] = Linko.util.copy(out);
							return out;
						},
						'error': function () {
							cb([]);
						},
						'success': cb
					}
				});
			}
			catch (e) {
				return [];
			}
		};
	})();

	YouTube.MAX_PAGE_SIZE = 50;

	YouTube.prototype.noLimitRequest = function (args) {
		var virtualPage     = args.params.page;
		var virtualPageSize = args.params.pageSize;
		delete args.params.page;
		delete args.params.pageSize;

		var baseIndex = (virtualPage - 1) * virtualPageSize;

		var userCallbacks = Linko.util.extendcb({}, args.callbacks);

		var items           = [];
		var totalItems      = 0;
		var requestCount    = Math.ceil(virtualPageSize / YouTube.MAX_PAGE_SIZE);
		var responseCounter = 0;

		var makeFeed = function () {
			return {
				'items'     : items,
				'totalItems': totalItems,
				'page'      : virtualPage,
				'pageSize'  : virtualPageSize
			};
		};

		var cb = function (startIndex) {
			return function (data) {
				var feed = renamers.videoFeed(data.feed);

				if (feed.totalItems > totalItems) {
					totalItems = feed.totalItems;
				}

				logger.trace(Linko.util.sprintf('noLimitRequest: Got a feed, startIndex %d and pageSize %d', startIndex, feed.pageSize));
				logger.trace(Linko.util.sprintf('noLimitRequest: Storing to indices [%d .. %d)', startIndex, startIndex + feed.pageSize));

				if (feed.items) {
					var maxI = Math.min(feed.pageSize, Math.min(feed.items.length, virtualPageSize - startIndex));
					for (var i = 0; i < maxI; ++i) {
						items[startIndex + i] = feed.items[i];
					}
				}

				if (++responseCounter === requestCount) {
					Linko.util.callAll(userCallbacks.success, makeFeed());
				}
			};
		};

		var error = (function () {
			var called = false;
			return function () {
				if (called) {
					return;
				}
				called = true;
				logger.error('noLimitRequest: failed', arguments);
				Linko.util.callAll(userCallbacks.error);
			};
		})();

		var fetched = 0;
		for (var i = 0; i < requestCount; ++i) {
			var startIndex = baseIndex + i * YouTube.MAX_PAGE_SIZE;
			args.params['start-index'] = startIndex + 1;
			args.params['max-results'] = Math.min(YouTube.MAX_PAGE_SIZE, virtualPageSize - fetched);

			args.async     = !!userCallbacks.success;
			args.callbacks = {
				'success': cb(startIndex),
				'error'  : error
			};

			this.request(args);
			fetched += args.params['max-results'];
		}

		// synchronous
		if (responseCounter === requestCount) {
			return makeFeed();
		}

		return null;
	};

	// If we're authorized, the sessionToken is sent automatically.
	// By default, an authToken is not used even if we have one.
	YouTube.prototype.request = function (args) {
		args = Linko.util.extend({}, args);

		if (typeof args.token === 'undefined' && this.authorized && this.sessionToken) {
			args.token = this.sessionToken;
		}

		args.headers = args.headers || {};
		if (args.token) {
			args.headers['Authorization'] = 'AuthSub token="' + args.token + '"';
		}

		if (this.developerKey) {
			args.headers['X-GData-Key'] = 'key=' + this.developerKey;
		}

		if (this.clientId) {
			args.headers['X-GData-Client'] = this.clientId;
		}

		if (!args.method) {
			args.method = 'GET';
		}

		args.params = Linko.util.extend(
			{ 'strict':true, 'v':2 },
			args.params
		);

		if (Linko.system.initialized && (!Linko.util.isEmpty(args.headers) || args.method !== 'GET' || args.jsonp === false || !Linko.conf.util.ajax['native'])) {
			args.params = Linko.util.extend({ 'alt':'json' }, args.params);
		}
		else {
			args.params = Linko.util.extend({ 'alt':'json-in-script' }, args.params);
			args.jsonp  = true;
			args.async  = true;
			args.jsonpCallback = 'callback';
			args.parseJSON = true;
		}

		if (typeof args.params['page'] !== 'undefined' || args.params['pageSize']) {
			var page     = typeof args.params['page']     === 'undefined' ?  1 : args.params['page'];
			var pageSize = typeof args.params['pageSize'] === 'undefined' ? 10 : args.params['pageSize'];

			if (pageSize > YouTube.MAX_PAGE_SIZE) {
				return this.noLimitRequest(args);
			}

			delete args.params['page'];
			delete args.params['pageSize'];

			args.params['start-index'] = (page - 1) * pageSize + 1;
			args.params['max-results'] = pageSize;
		}

		return Linko.util.ajax(args);
	};

	return YouTube;
})();
// Linko.pl - end of file 'sites/YouTube.js'
// Linko.pl - start of file 'Statement.js'

Linko.Statement = (function () {
	var logger = Linko.log.getLogger('Linko.Statement');

	var Statement = function (impl) {
		if (!impl) {
			logger.error('constructor: no impl');
			return null;
		}
		this.impl = impl;
	};

	Statement.logger = logger;

	/**
	 * @param {Number} index first index is 1
	 * @param {String|Number} value
	 */
	Statement.prototype.bindParameter = function (index, value) {
		try {
			this.impl.BindParameter(index, value);
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'bindParameter');
		}
		return false;
	};

	Statement.prototype.bind = function (values) {
		if (!Array.isArray(values)) {
			logger.error('bind: the parameter is not an array');
			return false;
		}
		for (var i = 0; i < values.length; ++i) {
			if (!this.bindParameter(i + 1, values[i])) {
				logger.error(Linko.util.sprintf('bind: this.bindParameter(%d,%s) failed', i + 1, values[i]));
				return false;
			}
		}
		return true;
	};

	Statement.prototype.execute = function () {
		var t1 = new Date();
		try {
			var rsImpl = this.impl.Execute()
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'execute');
			return null;
		}
		if (!rsImpl) {
			Linko.system.pluginError({'message':'impl.Execute() returned ' + rsImpl}, logger, 'execute');
			return null;
		}
		var t2 = new Date();
		var t = t2.getTime() - t1.getTime();
		if (t > 50) {
			logger.trace('execute: ' + t + ' ms');
		}

		return new Linko.ResultSet(rsImpl);
	};

	/**
	 * Clear all parameter bindings.
	 */
	Statement.prototype.clear = function () {
		try {
			this.impl.Clear();
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'clear');
		}
		return false;
	};

	// TODO: What's this for?
	Statement.prototype.reset = function () {
		try {
			this.impl.Reset();
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'reset');
		}
		return false;
	};

	return Statement;
})();

// Linko.pl - end of file 'Statement.js'
// Linko.pl - start of file 'Storage.js'

Linko.Storage = (function () {
	var logger = Linko.log.getLogger('Linko.Storage');
	var Storage = function (impl) {
		if (!impl) {
			logger.error('constructor: no impl');
			return null;
		}
		this.impl     = impl;
		this.contents = {};
	};

	Storage.prototype.getNamedFolder = function (name) {
		try {
			return new Linko.Folder(this.impl.GetNamedFolder(name));
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getNamedFolder');
		}
		return null;
	};

	Storage.prototype.getFreeSpace = function () {
		try {
			return Linko.system.isMac ? this.impl.GetPropertyFreeSpace() : this.impl.freeSpace;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getFreeSpace');
		}
		return null;
	};

	Storage.prototype.getTotalSpace = function () {
		try {
			return Linko.system.isMac ? this.impl.GetPropertyTotalSpace() : this.impl.totalSpace;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getTotalSpace');
		}
		return null;
	};

	Storage.prototype.getName = function () {
		try {
			return Linko.system.isMac ? this.impl.GetPropertyName() : this.impl.type;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getName');
		}
		return null;
	};

	Storage.prototype.getType = function () {
		try {
			return Linko.system.isMac ? this.impl.GetPropertyType() : this.impl.type;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getType');
		}
		return null;
	};

	var getContents = function (impl) {
		var out = {
			'files'  : [],
			'folders': []
		};

		try {
			var items = impl.GetItems();

			for (;;) {
				var elem = items.Next();

				if (!elem) {
					return out;
				}

				switch (elem.type) {
					case 'Folder':
						out.folders.push(new Linko.Folder(elem));
						break;
					case 'File':
						out.files.push(new Linko.File(elem));
						break;
					default:
						logger.error('getContents: Got an invalid item:', elem);
						return out;
				}
			}
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getContents');
		}

		return out;
	};

	// If limits are used, both start and count must be set.
	Storage.prototype.getFiles = function (start, count) {
		if (!this.contents.files) {
			this.contents = getContents(this.impl);
		}

		if (!start || !count) {
			return this.contents.files;
		}

		return this.contents.files.slice(start, start + count);
	};

	// If limits are used, both start and count must be set.
	Storage.prototype.getFolders = function (start, count) {
		if (!this.contents.folders) {
			this.contents = getContents(this.impl);
		}

		if (!start || !count) {
			return this.contents.folders;
		}

		return this.contents.folders.slice(start, start + count);
	};

	return Storage;
})();

// Linko.pl - end of file 'Storage.js'
// Linko.pl - start of file 'store/common.js'

Linko.initQueue.push(function () {
	Linko.system.store = Linko.store.getStore({
		'type': 'db',
		'db': 'site',
		'name': 'linko_temp'
	});
});

Linko.store = {
	'logger': Linko.log.getLogger('Linko.store'),
	'getStore': function (opts) {
		opts = Linko.util.extend({
			'type': 'cookie',
			'name': 'default'
		}, opts);

		switch (opts.type) {
		case 'stub':
			return new Linko.store.StubStore(opts);
		case 'cookie':
			return new Linko.store.CookieStore(opts);
		case 'object':
			return new Linko.store.ObjectStore(opts);
		case 'db':
			return new Linko.store.DBStore(opts);
		default:
			Linko.store.logger.error('constructor: invalid type:', opts.type, '- valid types are ["cookie","object","db"]');
			return null;
		}
	}
};
// Linko.pl - end of file 'store/common.js'
// Linko.pl - start of file 'store/CookieStore.js'

Linko.store.CookieStore = (function () {
	var logger = Linko.log.getLogger('Linko.store.CookieStore');
	var instances = {};
	var CookieStore = function (opts) {
		if (!instances[opts.name]) {
			this.prefix = 'linko_store_' + opts.name + '_';
			instances[opts.name] = this;
		}
		return instances[opts.name];
	};

	CookieStore.logger = logger;
	CookieStore.instances = instances;
	CookieStore.prototype = {
		'type': 'cookie',

		'get': function (key, def) {
			if (arguments.length < 2) {
				def = null;
			}
			var value = Linko.util.cookie.get(this.prefix + key);
			if (value === null) {
				return def;
			}
			return value;
		},

		'set': function (key, value) {
			value = String(value);
			Linko.util.cookie.set(this.prefix + key, value);
			return true;
		},

		'remove': function (key) {
			Linko.util.cookie.remove(this.prefix + key);
			return true;
		},

		'getContents': function () {
			var all = Linko.util.cookie.getAll();
			var self = this;
			var out = {};
			Linko.util.each(all, function (key, val) {
				if (key.slice(0, self.prefix.length) !== self.prefix) {
					return;
				}
				key = key.slice(self.prefix.length);
				out[key] = val;
			});
			return out;
		},

		'clear': function () {
			var self = this;
			Linko.util.each(this.getContents(), function (key, val) {
				self.remove(key);
			});
			return true;
		}
	};

	return CookieStore;
})();
// Linko.pl - end of file 'store/CookieStore.js'
// Linko.pl - start of file 'store/DBStore.js'

Linko.store.DBStore = (function () {
	var logger = Linko.log.getLogger('Linko.store.DBStore');
	var instances = {};
	var DBStore = function (opts) {
		if (!instances[opts.name]) {
			opts = Linko.util.extend({
				'db': 'common',
				'table': 'linko_store_' + opts.name
			}, opts);

			this.db = opts.db;
			if (typeof this.db === 'string') {
				switch (this.db.toLowerCase()) {
				case 'common':
					this.db = Linko.system.getCommonDB();
					break;
				case 'site':
					this.db = Linko.system.getSiteDB();
					break;
				default:
					logger.error('constructor: opts.db is invalid:', opts.db);
					return null;
				}
			}

			if (!this.db) {
				logger.error('constructor: no DB');
				return null;
			}

			this.table = opts.table.replace(/\W/g, '_');
			this._initOK = false;

			instances[opts.name] = this;
		}
		return instances[opts.name];
	};

	DBStore.logger = logger;
	DBStore.instances = instances;
	DBStore.prototype = {
		'type': 'db',

		'_init': function () {
			if (this._initOK) {
				return true;
			}

			if (!this.db) {
				logger.error('_init: no DB');
				return false;
			}

			if (!this.db.execute('CREATE TABLE IF NOT EXISTS ' + this.table + ' (key TEXT, value TEXT);')) {
				logger.error('_init: db.execute(CREATE TABLE...) failed');
				return false;
			}

			if (!this.db.execute('CREATE UNIQUE INDEX IF NOT EXISTS ' + this.table + '_unique_key ON ' + this.table + ' (key);')) {
				logger.error('_init: db.execute(CREATE UNIQUE INDEX...) failed');
				return false;
			}

			return this._initOK = true;
		},

		'get': function (key, def) {
			if (arguments.length < 2) {
				def = null;
			}
			if (!this._init()) {
				logger.error('get: this._init() failed');
				return def;
			}
			var rows = this.db.select({
				'cols': ['value'],
				'tables': [this.table],
				'where': ['key = ?'],
				'bind': [key]
			});
			if (!rows) {
				logger.error('get: rs.db.select() failed');
				return def;
			}
			if (rows.length === 0) {
				return def;
			}
			if (rows.length > 1) {
				logger.error('get: db.select() returned ' + rows.length + ' rows');
			}
			return rows[0].value;
		},

		'set': function (key, value) {
			if (!this._init()) {
				logger.error('set: this._init() failed');
				return false;
			}
			if (!this.remove(key)) {
				logger.error('set: this.remove() failed');
				return false;
			}
			var stmt = this.db.prepare('INSERT INTO ' + this.table + ' (key, value) VALUES (?, ?);');
			if (!stmt) {
				logger.error('set: db.prepare() failed');
				return false;
			}
			if (!stmt.bind([key, value])) {
				logger.error('set: stmt.bind() failed');
				return false;
			}
			if (!stmt.execute()) {
				logger.error('set: stmt.execute() failed');
				return false;
			}
			return true;
		},

		'remove': function (key) {
			if (!this._init()) {
				logger.error('remove: this._init() failed');
				return false;
			}
			var stmt = this.db.prepare('DELETE FROM ' + this.table + ' WHERE key = ?;');
			if (!stmt) {
				logger.error('remove: db.prepare() failed');
				return false;
			}
			if (!stmt.bind([key])) {
				logger.error('remove: stmt.bind() failed');
				return false;
			}
			if (!stmt.execute()) {
				logger.error('remove: stmt.execute() failed');
				return false;
			}
			return true;
		},

		'getContents': function () {
			if (!this._init()) {
				logger.error('getContents: this._init() failed');
				return null;
			}
			var rows = this.db.select({
				'cols': ['key', 'value'],
				'tables': [this.table]
			});
			if (!rows) {
				logger.error('getContents: db.select() failed');
				return null;
			}
			var out = {};
			rows.forEach(function (row) {
				out[row.key] = row.value;
			});
			return out;
		},

		'clear': function () {
			if (!this._init()) {
				logger.error('clear: this._init() failed');
				return false;
			}
			if (!this.db.execute('DELETE FROM ' + this.table + ';')) {
				logger.error('clear: db.execute() failed');
				return false;
			}
			return true;
		}
	};

	return DBStore;
})();
// Linko.pl - end of file 'store/DBStore.js'
// Linko.pl - start of file 'store/ObjectStore.js'

Linko.store.ObjectStore = (function () {
	var logger = Linko.log.getLogger('Linko.store.ObjectStore');
	var instances = {};
	var ObjectStore = function (opts) {
		if (!instances[opts.name]) {
			this.impl = {};
			instances[opts.name] = this;
		}
		return instances[opts.name];
	};

	ObjectStore.logger = logger;
	ObjectStore.instances = instances;
	ObjectStore.prototype = {
		'type': 'object',

		'get': function (key, def) {
			if (arguments.length < 2) {
				def = null;
			}
			if (this.impl.hasOwnProperty(key)) {
				return this.impl[key];
			}
			return def;
		},

		'set': function (key, value) {
			this.impl[key] = String(value);
			return true;
		},

		'remove': function (key) {
			delete this.impl[key];
			return true;
		},

		'getContents': function () {
			try {
				return Linko.util.copy(this.impl);
			}
			catch (e) {
				logger.error('getContents: Linko.util.copy() failed');
				return null;
			}
		},

		'clear': function () {
			this.impl = {};
			return true;
		}
	};

	return ObjectStore;
})();
// Linko.pl - end of file 'store/ObjectStore.js'
// Linko.pl - start of file 'store/StubStore.js'

Linko.store.StubStore = (function () {
	var logger = Linko.log.getLogger('Linko.store.StubStore');
	var instances = {};
	var StubStore = function (opts) {
		if (!instances[opts.name]) {
			instances[opts.name] = this;
		}
		return instances[opts.name];
	};

	StubStore.logger = logger;
	StubStore.instances = instances;
	StubStore.prototype = {
		'type': 'stub',

		'get': function (key, def) {
			if (arguments.length < 2) {
				def = null;
			}
			return def;
		},

		'set': function (key, value) {
			return false;
		},

		'remove': function (key) {
			return false;
		},

		'getContents': function () {
			return null;
		},

		'clear': function () {
			return false;
		}
	};

	return StubStore;
})();
// Linko.pl - end of file 'store/StubStore.js'
// Linko.pl - start of file 'system.js'

Linko.system = (function () {
	var logger = Linko.log.getLogger('Linko.system');
	var system = {
		'logger': logger
	};

	var eventStatusCodes = {
		'onDeviceNotification': {
			   1: 'Device added',
			   2: 'Device removed',
			   3: 'Memory card added',
			   4: 'Memory card removed',
			   5: 'wut onDeviceNotification 5',  // file system change?
			  10: 'wut onDeviceNotification 10',
			  11: 'Sync failed',
			  12: 'Sync canceled',
			  20: 'Sync started',
			  21: 'Scanning files',
			  22: 'Reading metadata',
			  23: 'Sync finished',
			 100: 'All syncs finished',
			 101: 'Backend blocked',
			 102: 'Backend unblocked'
		},
		'onDownloadProgress': {
			   0: 'Bunch begin',
			  11: 'Request error',
			  12: 'Download canceled',
			  13: 'File already exists',
			  15: 'Bunch failed',
			  49: 'Transfer queued',
			  50: 'Transfer begin',
			  51: 'Transfer progress',
			  52: 'Transfer end',
			  53: 'Bunch end',
			 100: 'Download queue end',
			 101: 'Download failed',
			 102: 'Download timeout',
			 103: 'Download canceled'
		},
		'onUploadProgress': {
			  11: 'Request error',
			  50: 'Transfer begin',
			  51: 'Transfer progress',
			  52: 'Transfer end'
		},
		'onPluginException': {
			1000: 'Plugin threw an exception'
		},
		'onSystemReady': {
			   5: 'System initialization OK',
			   6: 'Browser maybe incompatible',
			   7: 'Plugin update available'
		},
		'onError': {
			  11: 'Could not initialize plugin',
			  12: 'Could not connect to Dazz Media Server',
			  13: 'Could not create component',
			  14: 'Could not initialize Device Manager',
			  15: 'Incompatible OS',
			  16: 'Incompatible browser',
			  17: 'Plugin updated required',
			  18: 'Unspecified error',
			  19: 'Could not connect to BES'
		}
	};

	system.getAllStatusCodes = function () {
		return eventStatusCodes;
	};

	system.getStatusCodeInfo = function (event, code) {
		return Linko.util.getDef(null, eventStatusCodes, event, code);
	};

	/* The first parameter given to all event listeners is a status code.
	 * Additional parameters depend on the event and the code. They are listed below.
	 *
	 * onDeviceNotification [11, Object syncTask]
	 * onDeviceNotification [12, Object syncTask]
	 * onDeviceNotification [20, Object syncTask]
	 * onDeviceNotification [21, Object syncTask]
	 * onDeviceNotification [22, Object syncTask]
	 * onDeviceNotification [23, Object syncTask]
	 *
	 * onDownloadProgress [11, Object transferTask]
	 * onDownloadProgress [12, Object transferTask]
	 * onDownloadProgress [13, Object transferTask]
	 * onDownloadProgress [49, Object transferTask]
	 * onDownloadProgress [50, Object transferTask]
	 * onDownloadProgress [51, Object transferTask]
	 * onDownloadProgress [52, Object transferTask]
	 * onDownloadProgress [53, Object transferTask]
	 *
	 * onUploadProgress [11, Object transferTask]
	 * onUploadProgress [50, Object transferTask]
	 * onUploadProgress [51, Object transferTask]
	 * onUploadProgress [52, Object transferTask]
	 *
	 * onPluginException [1000, Linko.PluginException pe]
	 */

	var systemES = new Linko.EventSource(['onDeviceNotification', 'onDownloadProgress', 'onUploadProgress', 'onPluginException']);

	system.es = systemES;

	systemES.addEventListener(-1, 'onDeviceNotification', function (code) {
		if (code < 5) {
			if (Linko.compoundDB) {
				Linko.compoundDB.refresh();
			}
		}
	});

	system.addEventListener = function (event, listener) {
		return systemES.addEventListener(event, listener);
	};

	system.removeAllEventListeners = function () {
		Linko.util.each(systemES.listeners, function (key, val) {
			systemES.removeEventListener(val.id);
		});
	};

	systemES.addEventListener(-1, '*', function (event /* ... */) {
		var args = Linko.util.array(arguments).slice(1);
		var whatDoesItMean = eventStatusCodes[event][args[0]] || args[0];
		var logLevel = 'info';
		if (event === 'onPluginException') {
			return;
			//logLevel = 'error';
		}
		else if (event === 'onDeviceNotification') {
			if ([21,22].indexOf(args[0]) !== -1) {
				logLevel = 'debug';
			}
		}
		else if (event === 'onDownloadProgress') {
			if ([51].indexOf(args[0]) !== -1) {
				logLevel = 'debug';
			}
                         else if(args[0] === 100 && Linko.compoundDB){
                            Linko.compoundDB.refresh();
                        }
		}
		else if (event === 'onUploadProgress') {
			if ([51].indexOf(args[0]) !== -1) {
				logLevel = 'debug';
			}
		}
		logger[logLevel]('event "' + event + '"', whatDoesItMean, args);
	});

	system.fireEvent = function (event, args) {
		systemES.dispatchEvent(event, args);
	};

	/* Returns true if a plugin update is available. */
	var pluginUpdateAvailable = function () {
		if (!Linko.deviceManager) {
			logger.warn('pluginUpdateAvailable: no DeviceManager');
			return null;
		}

		if (!Linko.deviceManager.computer) {
			logger.warn('pluginUpdateAvailable: no computer');
			return null;
		}

		var currentVersion = system.pluginVersion;
		var availableVersion = Linko.deviceManager.computer.getAttribute('$pluginAvailable');

		if (!currentVersion || !availableVersion) {
			logger.warn('pluginUpdateAvailable: Invalid version (current = ' + currentVersion + ', available = ' + availableVersion + ')');
			return null;
		}

		var out = Linko.util.compareVersions(currentVersion, availableVersion) < 0;
		logger.debug('plutinUpdateAvailable: ' + out + ' (currentVersion = ' + currentVersion + ', availableVersion = ' + availableVersion + ')');
		return out;
	};

	Linko.util.extend(system, {
		'apiVersion': '3.0.9', // Linko.pl replaces this

		'pluginVersion': null,

		'isMac': null,
		'isIE':  null,
		'isXP':  null,

		// null before dazzler has been initialized
		// false if UAC is not enabled or we've been elevated
		// true if an UAC prompt could pop up
		'isUACEnabled': null,

		'debug': function (on) {
			var pc = Linko.util.get(Linko, 'deviceManager', 'computer');
			if (!pc) {
				logger.warn('debug: no computer');
				return false;
			}
			logger.info('Linko.system.debug = ' + !!on);
			pc.debug(on ? 'on' : 'off');
			return true;
		},

		'pluginError': function (e, srcLogger, src) {
			if (!srcLogger) {
				srcLogger = logger;
			}
			if (!src) {
				src = '';
			}

			var pe = new Linko.PluginException(e);
			srcLogger.error((src ? src + ': ' : '') + pe.toString());
			systemES.dispatchEvent('onPluginException', [1000, pe]);
			return true;
		},

		/**
		 * True after Linko.system.init has returned succesfully.
		 * @type {Boolean}
		 */
		'initialized': false,

		'getMediaServerURL': function () {
			return 'http://localhost:8888/';
		},

		'getDazzler': function (create) {
			var dazzler = document.getElementById('dazzler');
			if (!dazzler && create) {
				logger.debug('getDazzler: Creating dazzler <object>...');
				try {
					// Creating the <object> using DOM doesn't work in IE, so we use innerHTML.

					var dazzlerContainer = document.createElement('div');
					dazzlerContainer.setAttribute('id', 'dazzler-container');
					document.body.appendChild(dazzlerContainer);
					dazzlerContainer.innerHTML = '<object id="dazzler" type="application/x-linkotec-dazzler" width="1" height="1"></object>';
					//dazzlerContainer.innerHTML = (
					//	'<object id="dazzler-ie" classid="clsid:033CD4D2-E196-5EC9-B70D-7F2C6572771C" width="1" height="1">' +
					//	'  <embed id="dazzler" type="application/x-linkotec-dazzler" width="1" height="1" />
					//	'</object>'
					//);

					Linko.util.hideElement(dazzlerContainer);
					dazzler = document.getElementById('dazzler');
				}
				catch (e) {
					logger.error('getDazzler: creating dazzler <object> failed');
					return null;
				}
			}

			if (dazzler && system.isMac) {
				return {
					'impl':    dazzler,
					'version': dazzler.version(),
					'create':  Linko.util.method(dazzler, 'create'),
					'util':    Linko.util.method(dazzler, 'util')
				};
			}

			Linko._dazzler = dazzler;

			return dazzler;
		},

		/**
		 * Create a component.
		 * @param {String} component
		 * @return {Object|null}
		 */
		'create': function (component) {
			var dazzler = system.getDazzler();

			if (!dazzler) {
				logger.error('create: No dazzler');
				return null;
			}

			try {
				return dazzler.create('Linkotec.' + component);
			}
			catch (e) {
				system.pluginError(e, logger, 'create(' + component + ')');
				return null;
			}
		},

		'crash': function () {
			Linko.system.init({
				'success': function () {
					var db = system.getSiteDB();
					db.execute('DROP TABLE IF EXISTS plugin_crash;');
					db.execute('CREATE TABLE plugin_crash (column TEXT);');
					db.execute('INSERT INTO plugin_crash (column) VALUES ("kissa");');
					db.execute('INSERT INTO plugin_crash (column) VALUES ("kissa");');
					db.execute('CREATE UNIQUE INDEX plugin_crash_index ON plugin_crash (column);');
					db.execute('DROP TABLE plugin_crash');

					// The above was fixed on 2011-02-??

					Linko.deviceManager.computer.impl.exception;

					// The above was fixed on 2011-02-23
				}
			});
		},

		'testMD5': function () {
			try {
				var hash = null;
				if (system.isMac) {
					hash = system.getDazzler().util().MD5Hash('kissa');
				}
				else {
					var md5 = system.create('MD5Hash');
					if (!md5) {
						logger.error('testMD5: Failed to create MD5Hash');
						return false;
					}
					md5.Update('kissa');
					hash = md5.Finalize()
				}
				var correctHash = '1ad99cbe9e425d4f19c53a29d4f12597';
				if (hash !== correctHash) {
					logger.error('testMD5: Incorrect hash:', hash, 'expected:', correctHash);
					return false;
				}
				logger.debug('testMD5: OK');
				return true;
			}
			catch (e) {
				//logger.error('testMD5: exception', e);
				return false;
			}
		},

		/**
		 * Tries to determine whether Dazzler is installed without creating an <object> tag.
		 * @return {Boolean|null}
		 */
		'isDazzlerInstalled': function () {
			if (navigator.userAgent.match(/dazzMediaServer/i)) {
				logger.debug('isDazzlerInstalled: true (navigator.userAgent contains dazzMediaServer)');
				return true;
			}

			if (system.isIE) {
				logger.debug('isDazzlerInstalled: false (navigator.userAgent doesn\'t contain dazzMediaServer)');
				return false;
			}

			if (!navigator || !navigator.mimeTypes) {
				logger.debug('isDazzlerInstalled: null (navigator.mimeTypes doesn\'t exist)');
				return null;
			}

			var out = !!navigator.mimeTypes['application/x-linkotec-dazzler'];
			logger.debug('isDazzlerInstalled: ' + out + ' (based on navigator.mimeTypes)');
			return out;
		},

		'getPluginVersion': function (callback) {
			if (!callback) {
				callback = function (data) {
					logger.info('getPluginVersion: callback', data);
				};
			}

			Linko.environment.init();

			var dazzler = system.getDazzler(true);

			if (!dazzler) {
				return null;
			}

			var version = null;

			try {
				if (system.isMac) {
					var temp = dazzler.version;
					if (typeof temp === 'string') {
						version = temp;
					}
				}
				else if (system.isIE) {
					var match = navigator.userAgent.match(/dazzMediaServer (\d+(?:\.\d+)*)/i);
					if (match) {
						version = match[1];
					}
					// FIXME
					if (version === '1.4') {
						version = '1.4.1.4';
					}
				}
				else {
					var match = navigator.mimeTypes['application/x-linkotec-dazzler'].description.match(/(\d+\.\d+(?:\.\d+)*)/);
					if (match) {
						version = match[1];
					}
				}
			}
			catch (e) {
				logger.warn('getPluginVersion: exception:', e);
			}

			logger.debug('getPluginVersion: version = ' + version);

			var scriptId = 'linko-version-callback-' + Linko.util.uuid();
			window.Linko.versionCallbacks = {
				'deviceManager': function (data) {
					fail = Linko.noop;
					logger.info('Linko.versionCallbacks.deviceManager:', arguments);
					if (!data) {
						data = {};
					}
					if (!data.minimum) {
						data.minimum = '0';
					}
					if (!Array.isArray(data.blocked)) {
						data.blocked = [];
					}
					var updateRequired = !!version && !!data.minimum && Linko.util.compareVersions(version, data.minimum) < 0 || data.blocked.indexOf(version) !== -1;
					var updateAvailable = !!version && Linko.util.compareVersions(version, data.latest) < 0;
					callback({
						'updateRequired': updateRequired,
						'updateAvailable': updateRequired || updateAvailable,
						'latest': data.latest,
						'installed': version
					});

					$('#' + scriptId).remove();
				}
			};

			var script = document.createElement('script');
			script.setAttribute('id', scriptId);
			script.setAttribute('src', 'http://bes.linkotec.net/media/update/' + (system.isMac ? 'mac' : 'win') + '/versions.js');
			script.setAttribute('type', 'text/javascript');
			var fail = function () {
				fail = Linko.noop;
				logger.debug('getPluginVersion: <script> error event');
				callback({
					'failed':          true,
					'updateRequired':  null,
					'updateAvailable': null,
					'latest':          null,
					'installed':       version
				});
			};
			if (script.addEventListener) {
				script.addEventListener('error', fail, false);
			}
			else if (script.attachEvent) {
				script.attachEvent('onerror', fail);
				// IE fails at failing
				setTimeout(fail, 10000);
			}
			document.body.appendChild(script);

			return version;
		},

		'pingBES': function (callback) {
			if (!callback) {
				callback = Linko.noop;
			}
			system.getPluginVersion(function (data) {
				var ok = data && !data.failed;
				logger[ok ? 'info' : 'error']('pingBES: ' + (ok ? 'OK' : 'ERROR'));
				callback(ok);
			});
		},

		'isRunning': function (callback, timeout) {
			if (!timeout) {
				timeout = 1000;
			}
			var done = function (ok) {
				done = Linko.noop;
				Linko.util.callAll(callback, [ok]);
			};
			if (system.isMac) {
				return done(null);
			}
			var dazzler = system.getDazzler();
			if (dazzler && Linko.util.compareVersions('1.2.3', dazzler.version) > 0) {
				return done(null);
			}
			var uuid = Linko.util.uuid();
			Linko.util.ajax({
				'url': system.getMediaServerURL() + 'echo',
				'jsonp': true,
				'jsonpCallback': 'callback',
				'parseJSON': true,
				'method': 'GET',
				'async': true,
				'timeout': timeout,
				'params': {
					'format': 'jsonp'
				},
				'callbacks': {
					'success': function (data) {
						done(true);
					},
					'error': function () {
						done(false);
					}
				}
			});
		},

		'testEcho': function (callback) {
			var done = function (ok) {
				logger.debug('testEcho: ' + (ok ? 'OK' : 'ERROR'));
				Linko.util.callAll(callback, [ok]);
			};
			if (system.isMac) {
				logger.debug('testEcho: Not implemented on Mac');
				return done(true);
			}
			if (Linko.util.compareVersions('1.2.3', system.getDazzler().version) > 0 || Linko.util.parseQueryString().noEcho !== undefined) {
				logger.debug('testEcho: Not implemented, dazzler.version < 1.2.3');
				return done(true);
			}
			var uuid = Linko.util.uuid();
			Linko.util.ajax({
				'url': system.getMediaServerURL() + 'echo',
				'jsonp': true,
				'jsonpCallback': 'callback',
				'parseJSON': true,
				'method': 'GET',
				'async': true,
				'timeout': system.isIE || system.isMac ? 5000 : null,
				'params': {
					'uuid':   uuid,
					'format': 'jsonp'
				},
				'callbacks': {
					'success': function (data) {
						if (Linko.util.get(data, 'echo')) {
							data = data.echo;
						}
						if (Linko.util.get(data, 'params', 'uuid') === uuid) {
							return done(true);
						}
						if (Linko.util.parseQueryString(Linko.util.get(data, 'queryString')).uuid === uuid) {
							return done(true);
						}
						done(false);
					},
					'error': function () {
						done(false);
					}
				}
			});
		},

		'_initDazzlerTried': 0,
		'_initDazzlerOK': false,

		'initDazzler': function (callback) {
			Linko.environment.init();

			if (!callback) {
				callback = function (ok) {
					logger.info('initDazzler callback: ok = ' + ok);
				};
			}

			var dazzler = system.getDazzler(true);

			logger.debug('initDazzler: dazzler.version = ' + dazzler.version);
			logger.debug('initDazzler: typeof dazzler.version = ' + typeof dazzler.version);

			if (!dazzler || !(system.isMac ? dazzler.util : typeof dazzler.version === 'string')) {
				// At least Firefox 4 betas need this
				if (system._initDazzlerTried++ < 1) {
					logger.info('initDazzler: dazzler is not valid, trying again with a timeout');
					return void setTimeout(function () {
						system.initDazzler(callback);
					}, 500);
				}
				setTimeout(function () {
					logger.info('initDazzler: dazzler.version = ' + dazzler.version);
				}, 0);
				return void callback(false);
			}

			if (system._initDazzlerOK) {
				return void callback(true);
			}
			system._initDazzlerOK = true;
			logger.info('initDazzler: dazzler OK');

			// UAC

			if (system.isMac) {
				logger.debug('initDazzler:UAC: Mac, n/a');
				system.isUACEnabled = false;
			}
			else if (system.isXP) {
				logger.debug('initDazzler:UAC: XP, n/a');
				system.isUACEnabled = false;
			}
			else if (Linko.util.compareVersions('1.2.3', dazzler.version) > 0) {
				logger.info('initDazzler:UAC: Not implemented, dazzler.version < 1.2.3');
				system.isUACEnabled = false;
			}
			else {
				var elevated      = dazzler.elevated;
				var elevationType = dazzler.elevationType;
				logger.debug('initDazzler:UAC: dazzler.elevated = ' + elevated);
				logger.debug('initDazzler:UAC: dazzler.elevationType = "' + elevationType + '"');

				switch (true) {
				case elevated || elevationType === 'full':
					// The name 'isUACEnabled' is a bit misleading, but oh well
					system.isUACEnabled = false;
					break;
				case elevationType === 'limited':
					system.isUACEnabled = true;
					break;
				case elevationType === 'default':
					system.isUACEnabled = true;
					break;
				case true:
					// unknown
					system.isUACEnabled = true;
					break;
				}
			}

			logger.info('initDazzler:UAC: Linko.system.isUACEnabled = ' + Linko.system.isUACEnabled);

			callback(true);
		},

		'init': function (callbacks) {
			callbacks = Linko.util.extend({
				'testStart': function (name) {
					//logger.info('init: starting test: ' + name);
				},
				'testEnd': function (name, ok) {
					//if (ok) {
					//	logger.info('init: test OK');
					//}
					//else {
					//	logger.error('init: test ERROR');
					//}
				},
				'success': function () {
					logger.info('init: success callback');
				},
				'error': function (code /* ... */) {
					logger.error('init: error callback:', arguments);
				}
			}, callbacks);

			if (system.initialized) {
				logger.debug('init: called already');
				return void Linko.util.callAll(callbacks.success);
			}

			system.initDazzler(function (ok) {
				if (!ok) {
					logger.error('init: initDazzler failed');
					return void Linko.util.callAll(callbacks.error, [11]);
				}

				// Test MD5

				Linko.util.callAll(callbacks.testStart, ['MD5']);
				if (!system.testMD5()) {
					logger.error('init: testMD5: ERROR');
					Linko.util.callAll(callbacks.testEnd, ['MD5', false]);
					Linko.util.callAll(callbacks.error, [13, 'MD5Hash']);
					return;
				}
				logger.info('init: testMD5: OK');
				Linko.util.callAll(callbacks.testEnd, ['MD5', true]);

				// Test echo

				Linko.util.callAll(callbacks.testStart, ['echo']);
				system.testEcho(function (ok) {
					if (!ok) {
						logger.error('init: testEcho: ERROR');
						Linko.util.callAll(callbacks.testEnd, ['echo', false]);
						Linko.util.callAll(callbacks.error, [12]);
						return;
					}
					logger.info('init: testEcho: OK');
					Linko.util.callAll(callbacks.testEnd, ['echo', true]);

					// Create DeviceManager

					Linko.util.callAll(callbacks.testStart, ['DeviceManager']);
					if (!Linko.deviceManager) {
						Linko.deviceManager = new Linko.DeviceManager();
					}

					if (!Linko.deviceManager) {
						logger.info('init: DeviceManager: ERROR');
						Linko.util.callAll(callbacks.testEnd, ['DeviceManager', false]);
						Linko.util.callAll(callbacks.error, [14]);
						return;
					}
					Linko.util.callAll(callbacks.testEnd, ['DeviceManager', true]);

					logger.info('init: Created DeviceManager: OK');

					// Get devices

					Linko.util.callAll(callbacks.testStart, ['Get devices']);
					if (!Linko.deviceManager.updateDeviceList()) {
						Linko.util.callAll(callbacks.testEnd, ['Get devices', false]);
						logger.error('init: Updating device list failed, pinging BES');
						return void system.pingBES(function (ok) {
							Linko.util.callAll(callbacks.error, [ok ? 14 : 19]);
						});
					}
					Linko.util.callAll(callbacks.testEnd, ['Get devices', true]);

					logger.info('init: Updated device list: OK');

					system.pluginVersion = Linko.deviceManager.computer.getAttribute('$pluginVersion');

					// Check for updates

					Linko.util.callAll(callbacks.testStart, ['Checking for updates']);
					if (pluginUpdateAvailable()) {
						Linko.util.callAll(callbacks.testEnd, ['Checking for updates', false]);
						Linko.util.callAll(callbacks.error, [17, 'Plugin update required']);
						return;
					}
					Linko.util.callAll(callbacks.testEnd, ['Checking for updates', true]);

					// Initialize stuffs that need the plugin

					while (Linko.initQueue.length > 0) {
						var obj = Linko.initQueue.shift();
						if (typeof obj !== 'function') {
							logger.error('Invalid event in initQueue:', obj);
							continue;
						}
						obj();
					}

					system.initialized = true;
					logger.info('init: OK');
					Linko.util.callAll(callbacks.success, [5]);
				});
			});
		}
	});

	(function () {
		var siteDB = null;

		system.getSiteDB = function () {
			if (!siteDB) {
				var pc = Linko.util.get(Linko, 'deviceManager', 'computer');
				if (!pc) {
					return null;
				}
				siteDB = pc.getDatabase('SITE');
			}

			if (!siteDB) {
				return null;
			}

			return siteDB;
		};
	})();

	(function () {
		var commonDB = null;

		system.getCommonDB = function () {
			if (!commonDB) {
				var pc = Linko.util.get(Linko, 'deviceManager', 'computer');
				if (!pc) {
					return null;
				}
				commonDB = pc.getDatabase('COMMON');
			}

			if (!commonDB) {
				return null;
			}

			return commonDB;
		};
	})();

	return system;
})();
// Linko.pl - end of file 'system.js'
// Linko.pl - start of file 'iTunes.js'

Linko.system.iTunes = Linko.iTunes = (function () {
	var logger = Linko.log.getLogger('Linko.iTunes');

	var initQueue = [];
	var initCallback = function (ok) {
		if (ok === null) {
			ok = iTunes.initialized();
		}
		logger.info('init: ' + (ok ? 'OK' : 'ERROR'));
		initQueue.forEach(function (item) {
			item(ok);
		});
		initQueue = [];
	};

	Linko.system.addEventListener('onDeviceNotification', function (eventCode) {
		if (eventCode !== 102) {
			return;
		}
		initCallback(null);
	});

	var canHasAsync = function () {
		var bjsVersion = Linko.deviceManager.computer.getAttribute('$BJSVersion');
		var revision = +String(bjsVersion).match(/\d*$/);
		return revision >= 10;
	};

	var iTunes = {
		'logger': logger,

		'initialized': function () {
			var computer = Linko.deviceManager.computer;
			if (!computer) {
				return false;
			}

			var code = computer.genericInvoke('iTunes.Initialized', '');
			return code === '1';
		},

		'init': function (cb) {
			if (cb) {
				initQueue.push(cb);
			}

			var computer = Linko.deviceManager.computer;
			var newState = computer.genericInvoke('iTunes.Init', '');

			switch (newState) {
			case '0': // Unable to init
				initCallback(false);
				break;

			case '1': // Async init in progress
				break;

			case '2': // Init done
				initCallback(true);
				break;

			default:
				logger.error('iTunes: Invalid state from BES: "' + newState + '", expected one of [\'0\',\'1\',\'2\']');
				initCallback(false);
				break;
			}
		},

		'cache': {
			'_db': null,
			'_initDone': false,

			'getDB': function () {
				if (!this._db) {
					this._db = Linko.deviceManager.computer.getDatabase();
				}
				return this._db;
			},

			'init': function () {
				var db = this.getDB();
				if (!db) {
					logger.error('cache.init: no DB');
					return null;
				}

				//if (this._initDone) {
				//	return db;
				//}

				var ok = (
					db.execute(
						'CREATE TABLE IF NOT EXISTS linko_itunes_playlists (\n' +
						'  id INTEGER PRIMARY KEY,\n' +
						'  itunes_id INTEGER UNIQUE NOT NULL,\n' +
						'  title TEXT,\n' +
						'  updated DATETIME,\n' +
						'  json TEXT NOT NULL\n' +
						');'
					) &&
					db.execute(
						'CREATE TABLE IF NOT EXISTS linko_itunes_tracks (\n' +
						'  id INTEGER PRIMARY KEY,\n' +
						'  itunes_id INTEGER NOT NULL,\n' +
						'  title TEXT,\n' +
						'  album TEXT,\n' +
						'  artist TEXT,\n' +
						'  json TEXT NOT NULL\n' +
						');'
					) &&
					db.execute(
						'CREATE INDEX IF NOT EXISTS linko_itunes_tracks_playlist_id ON linko_itunes_tracks (itunes_id);'
					)
				);
				if (!ok) {
					logger.error('cache.init: db.execute() failed');
					return null;
				}
				logger.info('cache.init: OK');
				this._initDone = true;
				return db;
			},

			'getPlaylists': function () {
				var db = this.init();
				if (!db) {
					logger.error('cache.getPlaylists: init failed');
					return null;
				}
				var rows = db.select({
					'cols': [
						'linko_itunes_playlists.json AS json',
						'COUNT(linko_itunes_tracks.id) AS track_count'
					],
					'tables': [
						'linko_itunes_playlists',
						'LEFT JOIN linko_itunes_tracks ON\n' +
						'  linko_itunes_playlists.itunes_id = linko_itunes_tracks.itunes_id'
					],
					'group': [
						'linko_itunes_playlists.itunes_id'
					]
				});
				if (!rows) {
					logger.error('cache.getPlaylists: db.select() failed');
					return null;
				}
				return rows.map(function (row) {
					row.trackCount = row.track_count;
					delete row.track_count;
					try {
						Linko.util.extend(row, JSON.parse(row.json));
					}
					catch (e) {}
					delete row.tracks;
					return row;
				});
			},

			'getTracks': function (id) {
				var db = this.init();
				if (!db) {
					logger.error('cache.getPlaylists: init failed');
					return null;
				}
				var rows = db.select({
					'cols': ['json'],
					'tables': ['linko_itunes_tracks'],
					'where': ['linko_itunes_tracks.itunes_id = ?'],
					'bind': [id]
				});
				if (!rows) {
					logger.error('cache.getTracks: db.select() failed');
					return null;
				}
				return rows.map(function (row) {
					try {
						return JSON.parse(row.json);
					}
					catch (e) {
						return null;
					}
				}).filter(Linko.id);
			},

			'updatePlaylists': function (playlists) {
				if (!playlists) {
					return false;
				}
				var db = this.init();
				if (!db) {
					logger.error('cache.updatePlaylists: init failed');
					return false;
				}
				if (!db.execute('BEGIN TRANSACTION;')) {
					logger.error('cache.updatePlaylists: db.execute(\'BEGIN TRANSACTION;\') failed');
					return false;
				}
				var q = db.prepare('INSERT OR REPLACE INTO linko_itunes_playlists (itunes_id, title, updated, json) VALUES (?, ?, DATETIME(\'now\'), ?);');
				if (!q) {
					logger.error('cache.updatePlaylists: db.prepare() failed');
					return false;
				}
				playlists.forEach(function (playlist) {
					if (!q.clear()) {
						logger.error('cache.updatePlaylists: q.clear() failed');
						return;
					}
					if (!q.bind([playlist.id, playlist.name, JSON.stringify(playlist)])) {
						logger.error('cache.updatePlaylists: q.bind() failed');
						return;
					}
					if (!q.execute()) {
						logger.error('cache.updatePlaylists: q.execute() failed');
						return;
					}
				});
				if (!db.execute('COMMIT TRANSACTION;')) {
					logger.error('cache.updatePlaylists: db.execute(\'COMMIT TRANSACTION;\') failed');
					return false;
				}
				return true;
			},

			'updateTracks': function (id, tracks) {
				if (!id || !tracks) {
					return false;
				}
				var db = this.init();
				if (!db) {
					logger.error('cache.updateTracks: init failed');
					return false;
				}
				if (!db.execute('BEGIN TRANSACTION;')) {
					logger.error('cache.updateTracks: db.execute(\'BEGIN TRANSACTION;\') failed');
					return false;
				}
				var q = db.prepare('DELETE FROM linko_itunes_tracks WHERE itunes_id = ?;');
				if (!q) {
					logger.error('cache.updateTracks: db.prepare() failed');
					return false;
				}
				if (!q.bind([id])) {
					logger.error('cache.updateTracks: q.bind() failed');
					return false;
				}
				if (!q.execute()) {
					logger.error('cache.updateTracks: q.execute() failed');
					return false;
				}
				/*
				var insert = 'INSERT INTO linko_itunes_tracks (itunes_id, title, album, artist, json) VALUES (?, ?, ?, ?, ?);';
				var query = '';
				var bind = [];
				tracks.forEach(function (track) {
					query += insert;
					bind = bind.concat([id, track.name, track.album, track.artist, JSON.stringify(track)]);
				});
				var q = db.prepare(query);
				if (!q) {
					logger.error('cache.updateTracks: db.prepare() failed');
					return false;
				}
				if (!q.bind(bind)) {
					logger.error('cache.updateTracks: q.bind() failed');
					return false;
				}
				if (!q.execute()) {
					logger.error('cache.updateTracks: q.execute() failed');
					return false;
				}
				*/
				var q = db.prepare('INSERT INTO linko_itunes_tracks (itunes_id, title, album, artist, json) VALUES (?, ?, ?, ?, ?);');
				if (!q) {
					logger.error('cache.updateTracks: db.prepare() failed');
					return false;
				}
				tracks.forEach(function (track) {
					if (!q.clear()) {
						logger.error('cache.updateTracks: q.clear() failed');
						return;
					}
					if (!q.bind([id, track.name, track.album, track.artist, JSON.stringify(track)])) {
						logger.error('cache.updateTracks: q.bind() failed');
						return;
					}
					if (!q.execute()) {
						logger.error('cache.updateTracks: q.execute() failed');
						return;
					}
				});
				if (!db.execute('COMMIT TRANSACTION;')) {
					logger.error('cache.updateTracks: db.execute(\'COMMIT TRANSACTION;\') failed');
					return false;
				}
				return true;
			},

			'clear': function () {
				var db = this.init();
				if (!db) {
					logger.error('cache.clear: init failed');
					return false;
				}

				var ok = (
					db.execute('DROP TABLE linko_itunes_playlists;') &&
					db.execute('DROP TABLE linko_itunes_tracks;')
				);
				if (!ok) {
					logger.error('cache.clear: db.execute() failed');
					return false;
				}
				return true;
			}
		},

		/**
		 * Get a list of all playlists.
		 * @param {Function} callback
		 */
		'getPlaylists': function (callback) {
			if (!callback) {
				callback = Linko.noop;
			}

			var failed = function () {
				if (!canHasAsync()) {
					return void callback(null);
				}

				iTunes.init(function (ok) {
					if (ok) {
						iTunes.getPlaylists(callback);
					}
					else {
						callback(null);
					}
				});
			};

			var computer = Linko.deviceManager.computer;
			logger.trace('getPlaylists: calling genericInvoke(\'iTunes.Playlist\', \'music\')...');
			var str = computer.genericInvoke('iTunes.Playlist', 'music');

			var maxmsg = 20;
			var logmsg = str && str.length > maxmsg ? str.substr(0, maxmsg - 3) + '...' : str;
			logger.trace('getPlaylists: genericInvoke(\'iTunes.Playlist\', \'music\') =', logmsg);

			try {
				var playlists = JSON.parse(str);
				if (!Array.isArray(playlists) || (playlists.length === 1 && typeof playlists[0] === 'string')) {
					throw '';
				}
				iTunes.cache.updatePlaylists(playlists);
				return void callback(playlists);
			}
			catch (e) {
				logger.error('getPlaylists: invalid response from BES:', str);
				return void failed();
			}
		},

		'createPlaylist': function (ids, callback) {
			if (!callback) {
				callback = Linko.noop;
			}

			var failed = function () {
				if (!canHasAsync()) {
					return void callback(null);
				}

				iTunes.init(function (ok) {
					if (ok) {
						iTunes.createPlaylist(ids, callback);
					}
					else {
						callback(null);
					}
				});
			};

			var computer = Linko.deviceManager.computer;
			var str = computer.genericInvoke('iTunes.CreatePlaylist', ids.join(','));
			logger.trace('createPlaylist: GenericInvoke(\'iTunes.CreatePlaylist\', "' + ids.join(',') + '"\) =', str);

			return void callback(true);
		},

		/**
		 * Get all tracks in a playlist.
		 * @param {Number} id The playlist's id
		 * @param {Function} callback
		 */
		'getTracks': function (id, callback) {
			if (!callback) {
				callback = Linko.noop;
			}

			var failed = function () {
				if (!canHasAsync()) {
					return void callback(null);
				}

				iTunes.init(function (ok) {
					if (ok) {
						iTunes.getTracks(id, callback);
					}
					else {
						callback(null);
					}
				});
			};

			var computer = Linko.deviceManager.computer;
			logger.trace('getPlaylists: calling genericInvoke(\'iTunes.Playlist\', \'' + id + '\')...');
			var str = computer.genericInvoke('iTunes.Playlist', id);

			var maxmsg = 20;
			var logmsg = str && str.length > maxmsg ? str.substr(0, maxmsg - 3) + '...' : str;
			logger.trace('getTracks: genericInvoke(\'iTunes.Playlist\', \'' + id + '\') =', logmsg);
			try {
				var tracks = JSON.parse(str);
				if (!Array.isArray(tracks)) {
					throw '';
				}
				iTunes.cache.updateTracks(id, tracks);
			}
			catch (e) {
				return void failed();
			}
			return void callback(tracks);
		},

		/** Get all playlists and cache the metadata. */
		'importAll': function (callbacks) {
			callbacks = Linko.util.extendcb({
				'progress': function (done, total) {
					logger.debug('importAll: progress: ' + done + '/' + total);
				},
				'success': function () {
					logger.info('importAll: success');
				},
				'error': function (/* message */) {
					logger.error('importAll: error:', arguments);
				}
			}, callbacks);

			if (!this.cache.clear()) {
				return void Linko.util.callAll(callbacks.error, ['cache.clear() failed']);
			}

			this.getPlaylists(function (playlists) {
				if (!playlists) {
					return void Linko.util.callAll(callbacks.error, ['getPlaylists() failed']);
				}
				if (!iTunes.cache.updatePlaylists(playlists)) {
					return void Linko.util.callAll(callbacks.error, ['cache.updatePlaylists() failed']);
				}
				var go = function (i) {
					Linko.util.callAll(callbacks.progress, [i, playlists.length]);
					if (i >= playlists.length) {
						return void Linko.util.callAll(callbacks.success);
					}
					iTunes.getTracks(playlists[i].id, function (tracks) {
						if (!tracks) {
							return void Linko.util.callAll(callbacks.error, ['getTracks() failed']);
						}
						setTimeout(function () {
							if (!iTunes.cache.updateTracks(playlists[i].id, tracks)) {
								return void Linko.util.callAll(callbacks.error, ['cache.updateTracks() failed']);
							}
							go(i + 1);
						}, 0);
					});
				};
				setTimeout(function () { go(0); }, 0);
			});
		},

		'close': function () {
			return void Linko.deviceManager.computer.genericInvoke('iTunes.Close', '');
		}
	};

	return iTunes;
})();

// Linko.pl - end of file 'iTunes.js'
// Linko.pl - start of file 'sync.js'

Linko.system.sync = (function () {
	var logger = Linko.log.getLogger('Linko.sync');
	var sync = {
		'logger': logger
	};

	sync.start = function (source, target, files) {
		if (!source) {
			logger.error('start: no source device given');
			return false;
		}

		if (!target) {
			logger.error('start: no target device given');
			return false;
		}

		if (!files) {
			logger.error('start: no files given');
			return false;
		}

		if (!Array.isArray(files)) {
			logger.error('start: files is not an array');
			return false;
		}

		files = files.map(function (file) {
			if (!file) {
				return null;
			}

			if (typeof file === 'string') {
				file = {
					'id': file
				};
			}

			if (!file.id) {
				return null;
			}

			file.sourceDevice = source;
			file.targetDevice = target;

			return file;
		}).filter(Linko.id);

		logger.info('start: calling downloader.start() with ' + files.length + ' files...');
		Linko.downloader.start(files);
		logger.info('start: OK');
		return true;
	};

	return sync;
})();

// Linko.pl - end of file 'sync.js'
// Linko.pl - start of file 'TaskMonitor.js'

Linko.initQueue.push(function () {
	var impl = Linko.deviceManager.computer.downloadFile().impl;
	var tm = new Linko.TaskMonitor(impl);
	if (!tm) {
		Linko.TaskMonitor.logger.error('Could not create Linko.taskMonitor');
		return;
	}
	tm.init();
	tm.attach();
	tm.reloadTaskList();
	Linko.taskMonitor = tm;
});

Linko.TaskMonitor = (function () {
	var created = false;
	var logger = Linko.log.getLogger('Linko.TaskMonitor');

	/**
	 * Linko.taskMonitor keeps track of all download and sync tasks.
	 */
	var TaskMonitor = function (impl) {
		if (created) {
			logger.error('constructor: only one instance can be created');
			return null;
		}
		created = true;
		this.impl = impl;

		if (!impl) {
			logger.error('constructor: no impl');
			return null;
		}

		/**
		 * The value returned by last genericInvoke('Transfer.List').
		 * @type {Object[]}
		 */
		this.taskList = [];

		/**
		 * Transfers.
		 *
		 * Keys are taskIds, values are objects:
		 * transfer: {
		 *     taskId:     Number
		 *     title:      String   // Can be anything
		 *     state:      String   // pending|active|canceled|failed|done
		 *     bunchId:    Number
		 *     bunchTitle: String
		 *     type:       String   // file|playlist|siteload|upload
		 *     percent:    Number   // 0 <= percent <= 100
		 *     source:     String   // device Id or domain
		 *     target:     String   // device Id
		 *     webForm:    Linko.WebForm // for uploads
		 *     metadata: {
		 *         artist: String   // '' if unknown
		 *         album:  String   // '' if unknown
		 *         track:  String   // '' if unknown
		 *     }
		 * }
		 */
		this.transfers = {};

		/**
		 * Syncs.
		 *
		 * sync: {
		 *     taskId:    Number
		 *     title:     String
		 *     state:     String // pending|active|canceled|failed|done
		 *     fileCount: Number
		 *     percent:   Number
		 *     device:    String // device Id
		 * }
		 */
		this.syncs = {};
	};

	TaskMonitor.logger = logger;

	/**
	 * Get all playlist transfers.
	 * @return {Object}
	 */
	TaskMonitor.prototype.getPlaylists = function () {
		var out = {};
		Linko.util.each(this.transfers, function (taskId, transfer) {
			if (transfer.type === 'playlist') {
				out[taskId] = transfer;
			}
		});
		return out;
	};

	TaskMonitor.prototype.getTransfers = function () {
		return Linko.util.copy(this.transfers);
	};

	TaskMonitor.prototype.getActiveTransfers = function () {
		var out = {};
		Linko.util.each(this.transfers, function (taskId, transfer) {
			if (transfer.state === 'active' && transfer.percent !== 100) {
				out[taskId] = Linko.util.copy(transfer);
			}
		});
		return out;
	};

	TaskMonitor.prototype.getTODOTransfers = function () {
		return Linko.util.filter(this.transfers, function (transfer) {
			return transfer.state === 'pending' || transfer.state === 'active' && transfer.percent !== 100;
		});
	};

	TaskMonitor.prototype.getSyncs = function () {
		return Linko.util.copy(this.syncs);
	};

	TaskMonitor.prototype.getActiveSyncs = function () {
		var out = {};
		Linko.util.each(this.syncs, function (taskId, sync) {
			if (sync.state === 'active') {
				out[taskId] = sync;
			}
		});
		return out;
	};

	TaskMonitor.prototype.getTODOSyncs = function () {
		return Linko.util.filter(this.syncs, function (sync) {
			return sync.state === 'active' || sync.state === 'pending';
		});
	};

	/**
	 * Remove a transfer from cache.
	 * @param {Number} taskId
	 */
	TaskMonitor.prototype.deleteTransfer = function (taskId) {
		delete this.transfers[taskId];
	};

	/**
	 * Remove a sync task from cache.
	 * @param {Number} taskId
	 */
	TaskMonitor.prototype.deleteSync = function (taskId) {
		delete this.syncs[taskId];
	};

	TaskMonitor.prototype.updateTransfers = function (status, taskId, percent) {
		if (status === 100) {
//			this.transfers = {};
			return;
		}

		if (typeof percent !== 'number' || isNaN(percent) || !isFinite(percent)) {
			percent = 0;
		}

		var transfer = this.transfers[taskId];
		if (!transfer) {
			this.reloadTaskList();
			transfer = this.transfers[taskId];
		}

		if (!transfer) {
			if (!(status === 52 || status === 53 || status === 51 && percent === 10000)) {
				//logger.groupCollapsed('updateTransfers: taskId not found', Linko.util.array(arguments));
				logger.error('updateTransfers: taskId not found', Linko.util.array(arguments));
				logger.error('updateTransfers: transfers =', Linko.util.copy(this.transfers));
				logger.error('updateTransfers: taskList =', Linko.util.copy(this.taskList));
				//logger.groupEnd();
			}
			return;
		}

		switch (status) {
		case 11:
			transfer.state = 'failed';
			break;
		case 12:
			transfer.state = 'canceled';
			break;
		case 50:
		case 51:
			transfer.state   = 'active';
			transfer.percent = percent;
			break;
		case 52:
			transfer.state = 'done';
//			this.deleteTransfer(taskId);
			break;
		case 53:
			break;
		}
	};

	TaskMonitor.prototype.updateSyncs = function (status, taskId, percent) {
		if (status === 100) {
//			this.syncs = {};
			return;
		}

		if (typeof percent !== 'number' || isNaN(percent) || !isFinite(percent)) {
			percent = 0;
		}

		var sync = this.syncs[taskId];
		if (!sync) {
			this.reloadTaskList();
			sync = this.syncs[taskId];
		}

		if (!sync) {
			if (status !== 23) {
				logger.error('updateSyncs: item not found, taskId =', taskId);
			}
			return;
		}

		switch (status) {
		case 11:
			sync.state = 'failed';
			break;
		case 12:
			sync.state = 'canceled';
			break;
		case 20:
			sync.state = 'active';
			break;
		case 21:
			sync.state = 'active';
			sync.fileCount = percent;
			break;
		case 22:
			sync.state = 'active';
			sync.percent = percent;
			break;
		case 23:
			sync.state = 'done';
//			this.deleteSync(taskId);
			break;
		default:
			logger.error('updateSyncs: invalid status: "' + status + '"');
		}
	};

	TaskMonitor.prototype.getTaskById = function (taskId) {
		for (var i = 0; i < this.taskList.length; ++i) {
			if (this.taskList[i].id === taskId) {
				return this.taskList[i].id;
			}
		}

		this.reloadTaskList();

		for (var i = 0; i < this.taskList.length; ++i) {
			if (this.taskList[i].id === id) {
				return this.taskList[i].id;
			}
		}

		return null;
	};

	TaskMonitor.prototype.getTaskType = function (status, taskId) {
		if ([20,21,22,23].indexOf(status) !== -1) {
			return 'sync';
		}
		else if ([13,50,51,52,53].indexOf(status) !== -1) {
			return 'transfer';
		}
		else if (status === 100) {
			return 'all';
		}

		// 11 and 12 can be either 'sync' or 'transfer'
		if ([11,12].indexOf(status) !== -1) {
			if (!(this.transfers[taskId] || this.syncs[taskId])) {
				this.reloadTaskList();
			}

			if (this.syncs[taskId]) {
				return 'sync';
			}
			else if (this.transfers[taskId]) {
				return 'transfer';
			}
		}

//		logger.error('Linko.TaskMonitor::getTaskType: fail', status, taskId);
		return null;
	};

	TaskMonitor.prototype.callback = function (status, taskId, percent) {
		if (status === 52) {
			this.callback(51, taskId, 10000);
		}

		if (status === 23) {
			this.callback(22, taskId, 10000);
		}

		if (typeof percent !== 'number' || isNaN(percent) || !isFinite(percent)) {
			percent = 0;
		}

		// we fire either "all syncs done" or "all downloads done" later if needed
		if (status === 100) {
			this.clearTaskList();
//			this.transfers = {};
//			this.syncs     = {};
			this.reloadTaskList();
			//logger.trace('callback:', Linko.util.array(arguments), Linko.util.copy(this.taskList));
			return;
		}

		//logger.trace('callback:', Linko.util.array(arguments), Linko.util.copy(this.taskList));

		// percent is between 0 and 10000
		// but if status is 21, it is the number of files scanned
		if (status !== 21) {
			percent /= 100;
		}

		var taskType = this.getTaskType(status, taskId);

		if (!(taskType === 'sync' || taskType === 'transfer')) {
			logger.error('callback: Unknown taskId:', taskId);
			taskType = 'transfer';
		}

		if (taskType === 'sync') {
			this.updateSyncs(status, taskId, percent);
			var sync = Linko.util.extend({}, this.syncs[taskId] || null);
			var params = [status];

			switch (status) {
			case 11:
				params = [status, sync];
				break;
			case 12:
				params = [status, sync];
				break;
			case 20:
				params = [status, sync, 0];
				break;
			case 21:
				params = [status, sync, percent];
				break;
			case 22:
				params = [status, sync, percent];
				break;
			case 23:
				params = [status, sync, 100];
				break;
			default:
				logger.error('callback: invalid status: "' + status + '"');
				break;
			}

			Linko.system.fireEvent('onDeviceNotification', params);

			if (status === 12 || status === 23) {
				this.reloadTaskList();
				var self = this;
				setTimeout(function () {
					if (Linko.util.isEmpty(self.getTODOSyncs())) {
						Linko.system.fireEvent('onDeviceNotification', [100]);
					}
				}, 500);
			}
		}
		else if (taskType === 'transfer') {
			this.updateTransfers(status, taskId, percent);
			var transfer = Linko.util.copy(this.transfers[taskId]);
			if (!transfer) {
				logger.error('callback: Unknown taskId:', taskId);
				return;
			}
			var params = [status, transfer];

			switch (status) {
			case 11:
				params = [status, transfer];
				break;
			case 12:
				params = [status, transfer];
				break;
			case 13:
				params = [status, transfer];
				break;
			case 15:
				params = [status, transfer];
				break;
			case 50:
				params = [status, transfer, 0];
				break;
			case 51:
				params = [status, transfer, percent];
				break;
			case 52:
				params = [status, transfer, 100];
				break;
			case 53:
				params = [status, transfer, 100];
				break;
			}
			Linko.system.fireEvent('onDownloadProgress', params);

			if (status === 15) {
				Linko.system.fireEvent('onDownloadProgress', [53, transfer, 100]);
				status = 53;
			}

			var timeout = 500;
			if (status === 13) {
				timeout = 2000;
			}
			if ([11,13,52,53].indexOf(status) !== -1) {
				this.reloadTaskList();
				var self = this;
				setTimeout(function () {
					if (Linko.util.isEmpty(self.getTODOTransfers())) {
						Linko.system.fireEvent('onDownloadProgress', [100]);
					}
				}, timeout);
			}
		}
		else {
			logger.assert(false, 'unpossible!');
		}

//		logger.trace(this.activeTransfers.map(function (bunch) { return bunch.items.map(function (transfer) { return transfer.percent; }); }), this.activeSyncs);
	};

	TaskMonitor.prototype.attach = function () {
		this.setMimeType('listener/attach');
		this.impl.Begin();
	};

	TaskMonitor.prototype.detach = function () {
		this.setMimeType('listener/detach');
		this.impl.Begin();
	};

	TaskMonitor.prototype.init = function (src) {
		this.setName('TaskMonitor-' + Linko.util.uuid());

		var self = this;
		var cb = function () { return self.callback.apply(self, arguments); };

		if (Linko.system.isMac) {
			this.impl.SetProgressCallback(cb);
		}
		else {
			this.impl.AttachEventHandler('onProgress', cb);
		}
	};

	TaskMonitor.prototype.setName = function (name) {
		if (Linko.system.isMac) {
			this.impl.SetName(name);
		}
		else {
			this.impl.name = name;
		}
	};

	TaskMonitor.prototype.setMimeType = function (name) {
		if (Linko.system.isMac) {
			this.impl.SetMimeType(name);
		}
		else {
			this.impl.mimeType = name;
		}
	};

	TaskMonitor.prototype.newTransfer = (function () {
		var fired = {};
		return function (transfer) {
			if (!fired[transfer.id]) {
				Linko.system.fireEvent('onDownloadProgress', [49, Linko.util.copy(transfer)]);
				fired[transfer.id] = true;
			}
		};
	})();

	/**
	 * Fetch the task list from backend and update caches.
	 */
	TaskMonitor.prototype.reloadTaskList = function () {
		var computer = Linko.deviceManager.computer;
		var str = computer.genericInvoke('Transfer.List', '*');
		if (!str) {
			logger.error('reloadTaskList: genericInvoke(\'Transfer.List\') failed:', str);
			this.taskList = [];
			return false;
		}

		try {
			this.taskList = JSON.parse(str);
			if (!Array.isArray(this.taskList)) {
				throw 'Not an array';
			}
		}
		catch (e) {
			logger.error('reloadTaskList: genericInvoke(\'Transfer.List\') returned invalid JSON:', str);
			this.taskList = [];
		}

//		this.activeTransfers = {};
//		this.activeSyncs     = [];

		var self = this;
		this.taskList.forEach(function (task) {
			var appOptions = null;
			try {
				if (task.appOptions) {
					appOptions = JSON.parse(task.appOptions);
				}
			}
			catch (e) {
				logger.error('reloadTaskList: task.appOptions is not valid JSON:', task.appOptions);
			}

			switch (task.action) {
			case 'transfer':
			case 'playlist':
			case 'siteload':
				var metadata = {
					'artist': '',
					'album':  '',
					'track':  ''
				};
				var m = /^([^|]*)\|([^|]*)\|(.*)$/.exec(task.title);
				if (m) {
					var f = function (s) { return !s || s === 'undefined' ? '' : s; };
					metadata.artist = f(m[1]);
					metadata.album  = f(m[2]);
					metadata.track  = f(m[3]);
				}
				self.transfers[task.id] = {
					'id':         task.id,
					'state':      task.state === 'progress' ? 'active' : task.state || 'pending',
					'bunchId':    task.UIID,
					'title':      task.title || '',
					'percent':    task.current / task.total * 100 || 0,
					'type':       task.action === 'transfer' ? 'file' : task.action,
					'source':     task.from,
					'target':     task.to,
					'appOptions': appOptions,
					'metadata':   metadata
				};
				self.newTransfer(self.transfers[task.id]);
				break;
			case 'metasync':
				var fileCount = 0;
				var percent   = 0;

				if (task.eventId === 21) {
					fileCount = Math.max(fileCount, task.current);
				}
				else {
					percent = task.current / task.total;
				}

				if (typeof percent !== 'number' || isNaN(percent) || !isFinite(percent)) {
					percent = 0;
				}

				fileCount = Math.max(fileCount, task.current);

				self.syncs[task.id] = {
					'id'       : task.id,
					'state'    : task.state === 'progress' ? 'active' : task.state || 'pending',
					'fileCount': fileCount,
					'percent'  : percent,
					'device'   : task.to
				};
				break;
			default:
				logger.error('reloadTaskList: task.action is invalid:', task.action);
				break;
			}
		});

		return true;
	};

	TaskMonitor.prototype.cancelAllTransfers = function () {
		logger.info('cancelAllTransfers: starting...');
		var taskIds = Object.keys(this.getTODOTransfers());
		logger.debug('cancelAllTransfers: Got ' + taskIds.length + ' transfer IDs');
		var self = this;
		var go = function (i) {
			if (i >= taskIds.length) {
				logger.info('cancelAllTransfers: done');
				return;
			}
			self.cancelTask(taskIds[i]);
			setTimeout(function () {
				go(i + 1);
			}, 100);
		};
		go(0);
		logger.debug('cancelAllTransfers: started');
	};

	TaskMonitor.prototype.cancelTask = function (taskId) {
		var computer = Linko.deviceManager.computer;
		computer.genericInvoke('Transfer.Cancel', taskId);
		this.reloadTaskList();
	};

	// Clears the backend's task list. If we don't call this, it
	// keeps growing ad infinitum.
	TaskMonitor.prototype.clearTaskList = function () {
		var computer = Linko.deviceManager.computer;
		computer.genericInvoke('Transfer.Clear', '');
//		this.reloadTaskList();
	};

	// WebForm
	TaskMonitor.prototype.uploadStart = function (transfer) {
		this.transfers[transfer.taskId] = transfer;
	};

	TaskMonitor.prototype.uploadReadyStateChange = function (state, taskId) {
		logger.trace('uploadReadyStateChange', state, taskId);
		switch (state) {
		case 1:
			Linko.taskMonitor.callback(50, taskId, 0);
			break;
		case 2:
			break;
		case 3:
			break;
		case 4:
			Linko.taskMonitor.callback(52, taskId, 100);
			break;
		default:
			logger.error('uploadReadyStateChange: invalid state: "' + state + '"');
			break;
		}
	};

	/* Codes on Windows:
	 *
	 *   1 Finding resource
	 *   2 Connecting
	 *   3 Redirecting
	 *   4 Sending request
	 *   5 Receiving response
	 *   6 Request error
	 *
	 * Codes on Mac:
	 *
	 *   1 Starting
	 *   2 Upload started
	 *   3 Download started
	 *   50, 51, 52 Download events
	 *   4 Uploading
	 *   5 Upload complete
	 *   6 Error
	 *
	 * TODO: Currently, the 50-52 events are ignored.
	 */
	TaskMonitor.prototype.uploadProgress = function (code, message, done, total, taskId) {
		logger.trace('uploadProgress', Linko.util.array(arguments));
		switch (code) {
		case 1:
		case 2:
		case 3:
			break;
		case 4:
			Linko.taskMonitor.callback(51, taskId, !total ? 0 : done / total * 10000);
			break;
		case 5:
			Linko.taskMonitor.callback(52, taskId, 100);
			break;
		case 6:
			Linko.taskMonitor.callback(11, taskId);
			break;
		case 50:
		case 51:
		case 52:
			break;
		default:
			logger.error('uploadProgress: invalid code: "' + code + '"', Linko.util.array(arguments).slice(1));
			break;
		}
	};

	return TaskMonitor;
})();

// Linko.pl - end of file 'TaskMonitor.js'
// Linko.pl - start of file 'test/common.js'

Linko.test = {};

Linko.test.run = function (noskips) {
	var logger = Linko.log.getLogger('Linko.test');
	logger.additive = false;

	var aApp = new Linko.log.ArrayAppender({
		'threshold': Linko.log.Level.ALL
	});
	logger.addAppender(aApp);

	var dump = function () {
		logger.removeAppender(aApp);

		// We use a recursive function instead of a simple loop because of a bug
		// in Firebug 1.5.4. It seems that console.log returns early and does its job
		// asynchronously, so that the groups of later TestSuites are opened before
		// all earlier ones are ready and closed, producing a very confusing log.

		var throttle = Linko.system.isFF && Linko.environment.version < 4;

		var go = function (i, jumped) {
			if (i >= aApp.messages.length) {
				logger.appenders = [];
				return;
			}
			var event = aApp.messages[i];
			var jump = false;

			switch (event[0]) {
			case 'message':
				if (throttle && !jumped) {
					return void setTimeout(function () { go(i, true); }, 50);
				}
				logger.logMessage(event[1]);
				break;
			case 'group':
				if (throttle && !jumped) {
					return void setTimeout(function () { go(i, true); }, 500);
				}
				logger.group.apply(logger, event[1]);
				break;
			case 'groupEnd':
				if (throttle && !jumped) {
					return void setTimeout(function () { go(i, true); }, 500);
				}
				logger.groupEnd.apply(logger, event[1]);
				break;
			}
			go(i + 1);
		};
		go(0);
	};
	dump.messages = aApp.messages;

	var logSummary = function (results) {
		var summary = Linko.test.util.makeSummary(results);
		var level = Linko.test.util.summaryLevel(summary);
		logger[level](summary);
	};

	var allResults = [];
	var count      = 0;

	Linko.logger.info('Running tests...');
	logger.group('Tests', true);
	Linko.util.each(Linko.test.suites, function (name, suite) {
		suite.run(function (results) {
			logger.group(suite.title, false);
			results.forEach(function (result) {
				logger.logMessage(result.log());
			});
			logger.groupEnd();
			logSummary(results);

			allResults = allResults.concat(results);
			if (++count === Object.keys(Linko.test.suites).length) {
				logger.groupEnd();
				logSummary(allResults);
				Linko.logger.info('Tests finished');
				logger.additive = true;
			}
		}, noskips);
	});
	return dump;
};
// Linko.pl - end of file 'test/common.js'
// Linko.pl - start of file 'test/TestResult.js'

Linko.test.TestResult = (function () {
	var TestResult = function (testCase, status, message) {
		this.testCase = testCase;
		this.status   = status;
		this.message  = message || '';
	};

	TestResult.prototype.log = function () {
		var level = {
			'OK':   'INFO',
			'SKIP': 'INFO',
			'WARN': 'WARN',
			'FAIL': 'ERROR'
		}[this.status] || 'INFO';

		return new Linko.log.Message({
			'data':  [this.toString()],
			'level': Linko.log.Level[level].copy()
		});
	};

	TestResult.prototype.toString = function () {
		var message    = this.message ? ' - ' + this.message : '';
		var suite      = this.testCase.testSuite;
		var suiteTitle = ''; //suite && suite.title ? suite.title + ' - ' : '';
		return '[' + this.status + '] ' + suiteTitle + this.testCase.title + message;
	};

	return TestResult;
})();
// Linko.pl - end of file 'test/TestResult.js'
// Linko.pl - start of file 'test/TestCase.js'

Linko.test.TestCase = function (title, action, validate, skip) {
	this.title    = title;
	this.action   = action;
	this.validate = validate;
	this.skip     = skip;
};

Linko.test.TestCase.prototype.run = function (done, noskips) {
	var status = '';
	var self = this;

	var callDone = function () {
		callDone = Linko.noop;

		var m = status.match(/^(\w+)(?:\s+(.+))?$/) || ['', 'FAIL', 'invalid test status: "' + status + '"'];
		done(new Linko.test.TestResult(self, m[1], m[2] || ''));
	};

	setTimeout(function () {
		status = 'FAIL timed out';
		callDone();
	}, 30000);

	if (this.skip && !noskips) {
		status = 'SKIP';
		if (typeof this.skip === 'string' && this.skip !== '') {
			status += ' ' + this.skip;
		}
		callDone()
		return;
	}

	try {
		this.action(function (result) {
			status = self.validate(result, false);
			callDone();
		});
	}
	catch (e) {
		status = this.validate(e, true);
		callDone();
	}
};
// Linko.pl - end of file 'test/TestCase.js'
// Linko.pl - start of file 'test/TestSuite.js'

Linko.test.TestSuite = (function () {
	var TestSuite = function (title) {
		this.title     = title || null;
		this.testCases = [];
	};

	TestSuite.prototype.addTestCase = function (testCase) {
		this.testCases.push(testCase);
		testCase.testSuite = this;
	};

	TestSuite.prototype.run = function (done, noskips) {
		var results = [];
		var count   = 0;
		var self    = this;

		this.testCases.forEach(function (testCase, i) {
			testCase.run(function (result) {
				results[i] = result;
				if (++count === self.testCases.length) {
					done(results);
				}
			}, noskips);
		});
	};

	return TestSuite;
})();

// Linko.pl - end of file 'test/TestSuite.js'
// Linko.pl - start of file 'test/util.js'

Linko.test.util = {
	'eq': function (expectedValue, expectedThrow) {
		return function (resultValue, wasThrown) {
			var failMessage = function (message) {
				if (message) {
					message += ' -';
				}
				return Linko.util.sprintf('FAIL %s expected (%s) and got (%s)', message, JSON.stringify(expectedValue), JSON.stringify(resultValue));
			};

			if (expectedThrow && !wasThrown) {
				return failMessage('shoud have thrown, but didn\'t');
			}
			else if (!expectedThrow && wasThrown) {
				return failMessage('unexpected throw');
			}
			else if (!Linko.util.eq(expectedValue, resultValue)) {
				if (wasThrown) {
					return failMessage('wrong object was thrown');
				}
				else {
//					return failMessage('wrong return value');
					return failMessage('');
				}
			}

			return 'OK';
		};
	},

	'throwsAny': function (resultValue, wasThrown) {
		return wasThrown ? 'OK' : 'FAIL should have thrown, but didn\'t';
	},

	'makeSummary': function (results) {
		var counts = {};
		results.forEach(function (result) {
			if (!counts[result.status]) {
				counts[result.status] = 0;
			}
			++counts[result.status];
		});

		var summary = [];
		['OK', 'SKIP', 'WARN', 'FAIL'].forEach(function (type) {
			if (counts[type] > 0) {
				summary.push(Linko.util.sprintf('%d %s', counts[type], type));
			}
		});

		return summary.join(', ');
	},

	'summaryLevel': function (summary) {
		var level = 'info';
		if (summary.match(/FAIL/)) {
			level = 'error';
		}
		else if (summary.match(/WARN/)) {
			level = 'warn';
		}
		return level;
	}
};
// Linko.pl - end of file 'test/util.js'
// Linko.pl - start of file 'test/suites/common.js'

Linko.test.suites = {};

// Linko.pl - end of file 'test/suites/common.js'
// Linko.pl - start of file 'test/suites/Example.js'

Linko.test.suites['Example TestSuite'] = (function () {
	var suite = new Linko.test.TestSuite('Example TestSuite');

	suite.addTestCase(new Linko.test.TestCase(
		'Example 1',
		function (done) {
			done(Math.pow(2, 20));
		},
		Linko.test.util.eq(1048576)
	));

	suite.addTestCase(new Linko.test.TestCase(
		'Asynchronous Test 1',
		function (done) {
			setTimeout(function () {
				done(true);
			}, 3000);
		},
		Linko.test.util.eq(true),
		true
	));

	suite.addTestCase(new Linko.test.TestCase(
		'Asynchronous Test 2',
		function (done) {
			setTimeout(function () {
				done(true);
			}, 1000);
		},
		Linko.test.util.eq(true),
		true
	));

	return suite;
})();

// Linko.pl - end of file 'test/suites/Example.js'
// Linko.pl - start of file 'test/suites/MD5Hash.js'

Linko.test.suites['Linko.MD5Hash'] = (function () {
	var suite = new Linko.test.TestSuite('Linko.MD5Hash');

	suite.addTestCase(new Linko.test.TestCase(
		'#1',
		function (done) {
			var md5 = new Linko.MD5Hash();
			md5.init();
			md5.update('abcde');
			done(md5.getDigest());
		},
		Linko.test.util.eq('ab56b4d92b40713acc5af89985d4b786')
	));

	suite.addTestCase(new Linko.test.TestCase(
		'#2',
		function (done) {
			var md5 = new Linko.MD5Hash();
			md5.init();
			md5.update('abcdefghijklm');
			md5.update('nopqrstuvwxyz');
			done(md5.getDigest());
		},
		Linko.test.util.eq('c3fcd3d76192e4007dfb496cca67e13b')
	));

	suite.addTestCase(new Linko.test.TestCase(
		'#3',
		function (done) {
			var md5 = new Linko.MD5Hash();
			md5.init();
			md5.update('The quick brown');
			md5.update(' fox');
			md5.update('');
			md5.update('');
			md5.update('');
			md5.update(' ');
			md5.update('j');
			md5.update('u');
			md5.update('m');
			md5.update('p');
			md5.update('s');
			md5.update(' over the lazy dog');
			md5.update('.');
			done(md5.getDigest());
		},
		Linko.test.util.eq('e4d909c290d0fb1ca068ffaddf22cbd0'),
		'IsUnicode() fails for this case'
	));

	suite.addTestCase(new Linko.test.TestCase(
		'#4',
		function (done) {
			var md5 = new Linko.MD5Hash();
			md5.init();
			md5.update('The quick brown fox jumps over the lazy dog');
			done(md5.getDigest());
		},
		Linko.test.util.eq('9e107d9d372bb6826bd81d3542a419d6')
	));

	suite.addTestCase(new Linko.test.TestCase(
		'#5',
		function (done) {
			var md5 = new Linko.MD5Hash();
			done(md5.getDigest());
		},
		Linko.test.util.eq('d41d8cd98f00b204e9800998ecf8427e')
	));

	suite.addTestCase(new Linko.test.TestCase(
		'#6',
		function (done) {
			var md5 = new Linko.MD5Hash();
			md5.update(' ');
			done(md5.getDigest());
		},
		Linko.test.util.eq('7215ee9c7d9dc229d2921a40e899ec5f'),
		'IsUnicode() fails for this case'
	));

	suite.addTestCase(new Linko.test.TestCase(
		'#7',
		function (done) {
			var hashes = [];
			var md5 = new Linko.MD5Hash();
			md5.update('0');
			hashes.push(md5.getDigest());
			md5.update('1');
			hashes.push(md5.getDigest());
			md5.update('2');
			hashes.push(md5.getDigest());
			md5.update('3');
			hashes.push(md5.getDigest());
			md5.update('4');
			hashes.push(md5.getDigest());
			md5.update('5');
			hashes.push(md5.getDigest());
			md5.update('6');
			hashes.push(md5.getDigest());
			md5.update('7');
			hashes.push(md5.getDigest());
			md5.update('8');
			hashes.push(md5.getDigest());
			md5.update('9');
			hashes.push(md5.getDigest());
			done(hashes);
		},
		Linko.test.util.eq([
			'a46c3b54f2c9871cd81daf7a932499c0',
			'06d49632c9dc9bcb62aeaef99612ba6b',
			'6d5ababb65e9ff214b73e891b4afe6e8',
			'309fc7d3bc53bb63ac42e359260ac740',
			'cd1075d848a5e0142bd3b5d66726041c',
			'712a52bc5e7f8dc3cb5de157dbb08151',
			'854b85cbff2752fcb88606bca76f83c6',
			'a734c94d6e046c4667fea57758c5b6f6',
			'34f1bcfc647cfa2931f5b1e78d8011d2',
			'92e4d2da3d1528bc9f6668bbc26d633e'
		])
	));

	suite.addTestCase(new Linko.test.TestCase(
		'#8',
		function (done) {
			var md5 = new Linko.MD5Hash();
			md5.update('ingored lol');
			md5.init();
			md5.update('wut');
			done(md5.getDigest());
		},
		Linko.test.util.eq('c268120ce3918b1264fe2c05143b5c4b')
	));

	suite.addTestCase(new Linko.test.TestCase(
		'#9',
		function (done) {
			var md5 = new Linko.MD5Hash();
			md5.update('\u0000');
			done(md5.getDigest());
		},
		Linko.test.util.eq('c4103f122d27677c9db144cae1394a66'),
		'NULL bytes don\'t work'
	));

	return suite;
})();

// Linko.pl - end of file 'test/suites/MD5Hash.js'
// Linko.pl - start of file 'test/suites/util/cookie.js'

Linko.test.suites['Linko.util.cookie'] = (function () {
	var suite = new Linko.test.TestSuite('Linko.util.cookie');

	var cookie = Linko.util.cookie;

	suite.addTestCase(new Linko.test.TestCase(
		'#1',
		function (done) {
			var failed = false;
			var ok = function (bool) {
				if (!bool && !failed) {
					failed = true;
					done(false);
				}
			};

			var name = 'test-cookie-' + Math.random();
			cookie.remove(name);

			ok(cookie.get(name) === null);
			cookie.set(name, 'kissa');
			ok(cookie.get(name) === 'kissa');
			cookie.remove(name);
			ok(cookie.get(name) === null);
			cookie.set(name, 'koira', -1);
			ok(cookie.get(name) === null);
			cookie.remove(name);

			if (!failed) {
				done(true);
			}
		},
		Linko.test.util.eq(true)
	));

	suite.addTestCase(new Linko.test.TestCase(
		'#2',
		function (done) {
			var failed = false;
			var ok = function (bool) {
				if (!bool && !failed) {
					failed = true;
					done(false);
				}
			};

			var name = 'test-cookie-' + Math.random();
			cookie.remove(name);

			cookie.set(name, 'kissa', 1 / 3600 / 24);
			ok(cookie.get(name) === 'kissa');
			setTimeout(function () {
				ok(cookie.get(name) === null);
				cookie.remove(name);
				if (!failed) {
					done(true);
				}
			}, 2000);
		},
		Linko.test.util.eq(true)
	));

	return suite;
})();

// Linko.pl - end of file 'test/suites/util/cookie.js'
// Linko.pl - start of file 'test/suites/util/rename.js'

Linko.test.suites['Linko.util.rename'] = (function () {
	var suite = new Linko.test.TestSuite('Linko.util.rename');

	[
		// path

		[2, null, 2],
		[3, '', 3],
		[{a:5}, 'a', 5],
		[{a:5}, ['a'], 5],
		[{a:5}, {path:'a'}, 5],
		[{a:5}, {path:['a']}, 5],
		[{a:{b:5}}, ['a','b'], 5],
		[{a:{b:{c:5}}}, {'.x':{'.y':{'.z':['a','b','c']}}}, {x:{y:{z:5}}}],

		// constant

		[null, {constant:0}, 0],
		[{a:5,b:5}, {'.a':'a','.b':'b','.c':{constant:5}}, {a:5,b:5,c:5}],

		// required

		[{a:5}, { path:'a', required:true }, 5],
		[{b:5}, { path:'a', required:true }, null],
		[{b:5}, { path:'a', required:false }, undefined],

		// def

		[{b:5}, { path:'a', required:false, def:42 }, 42],
		[{b:5}, { path:'a', required:true, def:42 }, null],

		// ok

		[0, { required: true, ok: function (n) { return n !== 0; } }, null],
		[1, { required: true, ok: function (n) { return n !== 0; } }, 1],

		// filter

		[5, { filter: function (n) { return n * n; } }, 25],
		[-1, { required: true, filter: function (n) { if (n < 0) throw ''; return Math.sqrt(n); } }, null],
		[4,  { required: true, filter: function (n) { if (n < 0) throw ''; return Math.sqrt(n); } }, 2]

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'#' + (i + 1),
			function (done) {
				done(Linko.util.rename(opts[0], opts[1]));
			},
			opts[2] === null ? Linko.test.util.throwsAny : Linko.test.util.eq(opts[2])
		));
	});

	return suite;
})();

// Linko.pl - end of file 'test/suites/util/rename.js'
// Linko.pl - start of file 'test/suites/util/serialize.js'

Linko.test.suites['Linko.util.serialize'] = (function () {
	var suite = new Linko.test.TestSuite('Linko.util.serialize');

	[
		[{}, {}],
		[{a:5}, {a:'5'}],
		[{a:1,b:2,c:3,d:4,e:5}, {a:'1',b:'2',c:'3',d:'4',e:'5'}]

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'#' + (i + 1),
			function (done) {
				done(Linko.util.unserialize(Linko.util.serialize(opts[0])));
			},
			opts[1] === null ? Linko.test.util.throwsAny : Linko.test.util.eq(opts[1])
		));
	});

	return suite;
})();

// Linko.pl - end of file 'test/suites/util/serialize.js'
// Linko.pl - start of file 'test/suites/util/sprintf.js'

Linko.test.suites['Linko.util.sprintf'] = (function () {
	var suite = new Linko.test.TestSuite('Linko.util.sprintf');

	[
		// no extra params

		[[''], ''],
		[['lol'], 'lol'],

		// %%

		[['<%%%%%%>'], '<%%%>'],

		// #c

		[['<%c%c%c>', 108, 'o', 'l'], '<lol>'],

		// %s

		[['<%s,%.5s,%-5.3s>', 'schnuffel', 'stuffs OH NOES', 'omg1!1!!'], '<schnuffel,stuff,omg  >'],
		[['%.5s', '12345 NO U'], '12345'],

		// %d

		[['<%d,%d>', 2, 3], '<2,3>'],
		[['<%5d>', 100], '<  100>'],
		[['<%05d>', -11], '<-0011>'],
		[['<%-7d>', 13], '<13     >'],
		[['<%0+4d,%+04d,% 04d>', 5, -5, 5], '<+005,-005, 005>'],
		[['<%+7.4d>', 12], '<  +0012>'],

		// %o

		[['<%o,%o>', 8, -26], '<10,-32>'],
		[['<%#o,%#o>', 0, 1], '<0,01>'],

		// %x

		[['<%x,%#x,%#05X>', 10, 10, 10], '<a,0xa,0X00A>'],

		// %f

		[['<%f,%.4f,%#.0f,%.0f>', 12, 3.14159, 5, 1.234], '<12.000000,3.1416,5.,1>'],

		// %e

		[['<%e,%.4e,%#.0e,%.0e>', 12, 3.14159, 5, 1.234], '<1.200000e01,3.1416e00,5.e00,1e00>'],

		// *

		[['<%*d,%.*d,%*.*d>', 3, 1, 3, 1, 5, 3, 1], '<  1,001,  001>'],

		// %N$ and *N$

		[['<%1$d,%1$d,%1$d;%3$d,%2$d,%1$d;%1$*3$.*2$d>', 1, 3, 5], '<1,1,1;5,3,1;  001>']

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'#' + (i + 1),
			function (done) {
				done(Linko.util.sprintf.apply(null, opts[0]));
			},
			Linko.test.util.eq(opts[1])
		));
	});

	return suite;
})();

// Linko.pl - end of file 'test/suites/util/sprintf.js'
// Linko.pl - start of file 'test/suites/util.js'

Linko.test.suites['Linko.util'] = (function () {
	var suite = new Linko.test.TestSuite('Linko.util');

	/* Linko.util.array */

	(function () {
		var parameters = [null, undefined, {}, ['schnuffel'], [[[], 42]], undefined];
		suite.addTestCase(new Linko.test.TestCase(
			'array #1',
			function (done) {
				var array = (function () {
					return Linko.util.array(arguments);
				}).apply(null, parameters);

				if (!Array.isArray(array)) {
					throw 'not array';
				}

				done(array);
			},
			Linko.test.util.eq(parameters)
		));
	})();

	/* Linko.util.any */

	[
		[[],                           false],
		[[false,true],                 true],
		[[false,false,0,null,''],      false],
		[{},                           false],
		[{a:true},                     true],
		[{a:false,b:null,c:undefined}, false],

		[[0,1,2,3,4], function (val)     { return val < 5; },     true],
		[[0,1,2,3,4], function (val)     { return val >= 5; },    false],
		[[0,0,0,0,0], function (val,key) { return key; },         true],
		[{a:false},   function (val,key) { return key === 'a'; }, true],
		[{a:false},   function (val,key) { return key !== 'a'; }, false]

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'any #' + (i + 1),
			function (done) {
				done(opts.length > 2 ? Linko.util.any(opts[0], opts[1]) : Linko.util.any(opts[0]));
			},
			Linko.test.util.eq(opts[opts.length - 1])
		));
	});

	/* Linko.util.all */

	[
		[[],                           true],
		[[false,true],                 false],
		[[false,false,0,null,''],      false],
		[{},                           true],
		[{a:true},                     true],
		[{a:false,b:null,c:undefined}, false],

		[[0,1,2,3,4], function (val)     { return val < 5; },     true],
		[[0,1,2,3,4], function (val)     { return val >= 5; },    false],
		[[0,0,0,0,0], function (val,i)   { return +i; },          false],
		[{a:false},   function (val,key) { return key === 'a'; }, true],
		[{a:false},   function (val,key) { return key !== 'a'; }, false]

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'all #' + (i + 1),
			function (done) {
				done(opts.length > 2 ? Linko.util.all(opts[0], opts[1]) : Linko.util.all(opts[0]));
			},
			Linko.test.util.eq(opts[opts.length - 1])
		));
	});

	/* Linko.util.map */

	[
		[[], []],
		[{}, {}],
		[[0,'a'], [0,'a']],
		[{a:0,b:1}, {a:0,b:1}],

		[[0,1,2,3,4],   function (val)     { return val * val; }, [0,1,4,9,16]],
		[[10,10,10],    function (val,i)   { return val + +i; },  [10,11,12]],
		[{a:1,b:2,c:3}, function (val)     { return -val; },      {a:-1,b:-2,c:-3}],
		[{x:4,y:5,z:6}, function (val,key) { return key + val; }, {x:'x4',y:'y5',z:'z6'}]

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'map #' + (i + 1),
			function (done) {
				done(opts.length > 2 ? Linko.util.map(opts[0], opts[1]) : Linko.util.map(opts[0]));
			},
			Linko.test.util.eq(opts[opts.length - 1])
		));
	});

	/* Linko.util.filter */

	[
		[[], []],
		[{}, {}],
		[[0,'a'], ['a']],
		[{a:0,b:1}, {b:1}],

		[[0,1,2,3,4],               function (val)     { return val % 2; },     [1,3]],
		[{a:'a',b:'b',c:'C',e:'e'}, function (val,key) { return val === key; }, {a:'a',b:'b',e:'e'}]

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'filter #' + (i + 1),
			function (done) {
				done(opts.length > 2 ? Linko.util.filter(opts[0], opts[1]) : Linko.util.filter(opts[0]));
			},
			Linko.test.util.eq(opts[opts.length - 1])
		));
	});

	/* Linko.util.copy */

	[
		[null],
		[undefined],
		[15],
		['abc'],
		[[]],
		[{}]

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'copy #' + (i + 1),
			function (done) {
				done(Linko.util.copy(opts[0]));
			},
			Linko.test.util.eq(opts[0])
		));
	});

	/*
	[
		(function () {
			var x = {};
			x.x = x;
			return x;
		})(),
		(function () {
			var x = {a:{b:{c:{d:{e:{f:{g:null}}}}}}};
			x.a.b.c.d.e.f.g = x;
			return x;
		})(),
		(function () {
			var x = {
				a: 1,
				b: 2,
				c: {
					c: {
						c: [5,5,5]
					}
				},
				d: [null, undefined, [[]]]
			};
			x.x = x;
			x.e1 = {e2:null};
			x.e2 = {e1:null};
			x.e1.e2 = x.e2;
			x.e2.e1 = x.e1;
			return x;
		})()

	].forEach(function (x, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'copy circular #' + (i + 1),
			function (done) {
				done(Linko.util.copy(x));
			},
			Linko.test.util.eq(x)
		));
	});
	*/

	suite.addTestCase(new Linko.test.TestCase(
		'copy deep',
		function (done) {
			var failed = false;
			var ok = function (bool) {
				if (!bool && !failed) {
					failed = true;
					done(false);
				}
			};

			var xs = [0,[1],[[2]],[[[3]]],[[[[{a:{b:{c:100}}}]]]]];
			var ys = Linko.util.copy(xs);
			ok(Linko.util.eq(xs, ys));
			++ys[0];
			ok(xs[0] === 0);
			++ys[4][0][0][0][0].a.b.c;
			ok(xs[4][0][0][0][0].a.b.c === 100);

			/*
			var x = {a:1, b:2, x:null};
			x.x = x;
			var y = Linko.util.copy(x);
			ok(Linko.util.eq(x, y));
			x.a = 5;
			ok(y.a === 1);
			delete x.x;
			ok(y.x === y);
			*/

			if (!failed) {
				done(true);
			}
		},
		Linko.test.util.eq(true)
	));

	/* Linko.util.extend */

	[
		[[], {}],
		[[{a:5}], {a:5}],
		[[{a:5}, {a:10}], {a:10}],
		[[{a:5}, {b:6}, {c:7}], {a:5,b:6,c:7}],
		[
			[
				{a:{a:{a:0,b:0},b:{a:0,b:0}},b:{a:0,b:0}},
				{a:{a:{a:111}}},
				{a:undefined},
				{b:undefined},
				{a:{a:{b:112}}},
				{a:{b:{a:121,b:122}}},
				{b:{a:undefined,b:22}},
				{b:{a:21}}
			],
			{a:{a:{a:111,b:112},b:{a:121,b:122}},b:{a:21,b:22}}
		],
		[[null, [0,1,2]], [0,1,2]],
		[[[1,1,1,1,1],[undefined,2,2,2,2],[undefined,undefined,3,3,3],[undefined,undefined,undefined,4,4],[undefined,undefined,undefined,undefined,5]], [1,2,3,4,5]]

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'extend #' + (i + 1),
			function (done) {
				done(Linko.util.extend.apply(null, opts[0]));
			},
			Linko.test.util.eq(opts[1])
		));
	});

	suite.addTestCase(new Linko.test.TestCase(
		'extend in-place',
		function (done) {
			var failed = false;
			var ok = function (bool) {
				if (!bool && !failed) {
					failed = true;
					done(false);
				}
			};

			var x = {a:{b:100}};
			var y = Linko.util.extend(true, {}, x);
			++y.a.b;
			ok(y.a.b === 101 && x.a.b === 100);

			y = Linko.util.extend(true, {}, x, {a:{b:101}});
			ok(y.a.b === 101 && x.a.b === 100);

			y = Linko.util.extend(false, {}, x);
			++y.a.b;
			ok(y.a.b === 101 && x.a.b === 101);

			y = Linko.util.extend(false, x, {a:{b:101}});
			ok(y.a.b === 101 && x.a.b === 101);

			y = Linko.util.extend(true, x, {a:{b:102}});
			ok(y.a.b === 102 && x.a.b === 102);
			++y.a.b;
			ok(y.a.b === 103 && x.a.b === 103);

			done(true);
		},
		Linko.test.util.eq(true)
	));

	/* Linko.util.each */

	suite.addTestCase(new Linko.test.TestCase(
		'each #1',
		function (done) {
			var sumKeys = 0;
			var sumVals = 0;
			Linko.util.each({1:1, 2:5, 3:4, 4:3, 5:2, 6:6}, function (key, val) {
				sumKeys += Number(key);
				sumVals += val;
			});

			if (sumKeys !== 21 || sumVals !== 21) {
				throw 'fail';
			}

			done(true);
		},
		Linko.test.util.eq(true)
	));

	suite.addTestCase(new Linko.test.TestCase(
		'each #2',
		function (done) {
			var sumKeys = 0;
			var sumVals = 0;
			var counter = 0;
			Linko.util.each({1:1, 2:5, 3:4, 4:2, 5:2, 6:6}, function (key, val) {
				sumKeys += Number(key);
				sumVals += val;
				return ++counter < 3;
			});

			if (sumKeys !== 6 || sumVals !== 10) {
				throw 'fail';
			}

			done(true);
		},
		Linko.test.util.eq(true)
	));

	/* Linko.util.eq */

	[
		// numbers

		[0, 0, true],
		[4, 5, false],
		[Number.NaN, Number.NaN, false],

		// strings

		['',    '',    true],
		['wat', 'wut', false],

		// booleans

		[false, false, true],
		[true,  true,  true],
		[false, true,  false],
		[true,  false, false],

		// no fuzzy plox

		[0, '0',       false],
		[0, false,     false],
		[0, null,      false],
		[0, undefined, false],

		// null and undefined

		[null,      null,      true],
		[null,      undefined, false],
		[undefined, null,      false],
		[undefined, undefined, true],

		// arrays

		[[],             [],               true],
		[[[[[[0]]]]],    [[[[[1]]]]],      false],
		[[[[[[42]]]]],   [[[[[42]]]]],     true],
		[[[[[[42]]]]],   [[[[42]]]],       false],
		[['a','b',5],    ['a','b',5],      true],
		[['a','b',7],    ['b','a',7],      false],
		[[[null],[1],1], [[null],[1],[1]], false],
		[[0,0,0,0,0],    [0,0,0,0],        false],
		[{a:5},          {a:5},            true],
		[{a:5},          {a:6},            false],

		// objects

		[{},                      {},                      true],
		[{a:1},                   {a:1},                   true],
		[{a:1},                   {a:0},                   false],
		[{a:1,b:1},               {b:1,a:1},               true],
		[{a:{a:{a:[0,1,2]}},b:1}, {b:1,a:{a:{a:[0,1,2]}}}, true],
		[{a:{a:{a:[undefined]}}}, {a:{a:{a:[null]}}},      false],

		// different types

		[{}, [],   false],
		[{}, null, false],
		[{}, 0,    false],
		[[], null, false],
		[[], 0,    false],
		[1,  null, false]

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'eq #' + (i + 1),
			function (done) {
				done(Linko.util.eq(opts[0], opts[1]));
			},
			Linko.test.util.eq(opts[2])
		));
	});

	/* Linko.util.get */

	[
		[null, [], null],
		[['a'],  0, 'a'],
		[{a:{b:{c:{d:{e:10}}}}}, ['a','b','c','d','e'], 10],
		[{a:{b:{c:{d:{e:10}}}}}, ['a','b','c','d','f'], undefined],
		[{a:{b:{c:{d:{e:10}}}}}, ['a','b','c'],         {d:{e:10}}],
		[{a:{b:{c:{d:{e:10}}}}}, 'a', 'b', 'c', 'd', 'e', 10],
		[{a:{b:{c:{d:{e:10}}}}}, ['a', 'b'], 'c', 'd', 'e', 10],
		[{a:{b:{c:{d:{e:10}}}}}, 'a', ['b', 'c'], 'd', 'e', 10],
		[{a:{b:{c:{d:{e:10}}}}}, ['a'], ['b'], ['c'], ['d'], ['e'], 10],
		[{a:{b:{c:{d:{e:10}}}}}, 'a', 'b', 'c', 'd', 'e', 10],
		[{a:{b:{c:{d:10}}}}, 'a', 'b', 'c', 'd', 'e', undefined],
		[{a:{b:{c:{d:10}}}}, ['a', 'b'], 'c', 'd', 'e', undefined],
		[{a:{b:{c:{d:10}}}}, 'a', ['b', 'c'], 'd', 'e', undefined],
		[{a:{b:{c:{d:10}}}}, ['a'], ['b'], ['c'], ['d'], ['e'], undefined],
		[{a:{b:{c:{d:10}}}}, 'a', 'b', 'c', 'd', 'e', undefined]

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'get #' + (i + 1),
			function (done) {
				done(Linko.util.get.apply(null, opts.slice(0, opts.length - 1)));
			},
			Linko.test.util.eq(opts[opts.length - 1])
		));
	});

	/* Linko.util.isEmpty */

	suite.addTestCase(new Linko.test.TestCase(
		'isEmpty #1',
		function (done) {
			done(Linko.util.isEmpty({}));
		},
		Linko.test.util.eq(true)
	));

	suite.addTestCase(new Linko.test.TestCase(
		'isEmpty #2',
		function (done) {
			done(Linko.util.isEmpty({a:0}));
		},
		Linko.test.util.eq(false)
	));

	/* Linko.util.isCircular */

	[
		[false, null],
		[false, undefined],
		[false, 0],
		[false, 5],
		[false, Linko.noop],
		[false, []],
		[false, {}],
		[false, [0, null, [[[]]], {}]],
		[false, {a:{b:{}},c:[]}],
		[true, function () {
			var x = {};
			x.x = x;
			return x;
		}],
		[true, function () {
			var x = {};
			x.a = {b:{c:{d:{e:{f:x}}}}};
			return x;
		}],
		[false, function () {
			var a = {};
			return {a1:a,a2:a};
		}],
		[false, function () {
			var as = [];
			for (var i = 0; i < 5; ++i) {
				as[i] = {};
				for (var j = 0; j < i; ++j) {
					as[i]['a' + j] = as[j];
				}
			}
			return as[as.length - 1];
		}],
		[false, function () {
			var as = [];
			for (var i = 0; i < 5; ++i) {
				as[i] = [];
				for (var j = 0; j < i; ++j) {
					as[i].push(as[j]);
				}
			}
			return as[as.length - 1];
		}],
		[true, function () {
			var as = [];
			for (var i = 0; i < 5; ++i) {
				as[i] = {};
				for (var j = 0; j < i; ++j) {
					as[i]['a' + j] = as[j];
				}
			}
			as[0].fail = as[as.length - 1];
			return as[as.length - 1];
		}]
	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'isCircular #' + (i + 1),
			function (done) {
				done(Linko.util.isCircular(typeof opts[1] === 'function' ? opts[1]() : opts[1]));
			},
			Linko.test.util.eq(opts[0])
		));
	});

	/* Linko.util.keys */

	suite.addTestCase(new Linko.test.TestCase(
		'keys #1',
		function (done) {
			done(Linko.util.keys({}));
		},
		Linko.test.util.eq([])
	));

	suite.addTestCase(new Linko.test.TestCase(
		'keys #2',
		function (done) {
			done(Linko.util.keys({a:0,b:0}).sort());
		},
		Linko.test.util.eq(['a','b'])
	));

	/* Linko.util.makeQueryString */

	[
		['', ''],
		['', '', false],
		['', '', true],
		[{}, ''],
		[{}, '', false],
		[{}, '', true],

		['omg', 'omg'],
		['omg', 'omg',  false],
		['omg', '?omg', true],

		['=?#&', '=?#&'],
		['=?#&', '=?#&',  false],
		['=?#&', '?=?#&', true],

		[{a:'',b:'1',c:'...'}, 'a=&b=1&c=...'],
		[{a:'',b:'1',c:'...'}, 'a=&b=1&c=...',  false],
		[{a:'',b:'1',c:'...'}, '?a=&b=1&c=...', true],

		[{o:15,n:14,m:13,l:12,k:11,j:10,i:9,h:8,g:7,f:6,e:5,d:4,c:3,b:2,a:1}, '?a=1&b=2&c=3&d=4&e=5&f=6&g=7&h=8&i=9&j=10&k=11&l=12&m=13&n=14&o=15', true],

		[{'::%%':' #=@=/', '':'z42'}, '%3A%3A%25%25=%20%23%3D%40%3D%2F&=z42'],
		[{'"#<"\'&@@<@\'"<@>\'@\'= #"=\'"&<><&':'=\'\'>=','#@>=<"\'"@>"##"=<"@\'>@>#<&&<<"#':'"#"&@=><','#':' >>#>@=\'<  @','><""\'<>@<@\'\'""@>':''}, '?%22%23%3C%22\'%26%40%40%3C%40\'%22%3C%40%3E\'%40\'%3D%20%23%22%3D\'%22%26%3C%3E%3C%26=%3D\'\'%3E%3D&%23%40%3E%3D%3C%22\'%22%40%3E%22%23%23%22%3D%3C%22%40\'%3E%40%3E%23%3C%26%26%3C%3C%22%23=%22%23%22%26%40%3D%3E%3C&%23=%20%3E%3E%23%3E%40%3D\'%3C%20%20%40&%3E%3C%22%22\'%3C%3E%40%3C%40\'\'%22%22%40%3E=', true]

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'makeQueryString #' + (i + 1),
			function (done) {
				if (opts.length === 3) {
					done(Linko.util.makeQueryString(opts[0], opts[2]));
				}
				else {
					done(Linko.util.makeQueryString(opts[0]));
				}
			},
			Linko.test.util.eq(opts[1])
		));
	});

	/* Linko.util.parseQueryString */

	[
		['', {}],
		['a=5', {a:'5'}],
		['x=1&x=3&x=2', {x:['1','3','2']}],
		['foo=a==z&=&==', {'foo':'a==z','':['','=']}],
		['%3A%3A%25%25=%20%23%3D%40%3D%2F&=z42', {'::%%':' #=@=/', '':'z42'}]

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'parseQueryString #' + (i + 1),
			function (done) {
				done(Linko.util.parseQueryString(opts[0]));
			},
			Linko.test.util.eq(opts[1])
		));
	});

	/* Linko.util.trim */

	[
		['\t \n', ''],
		['| \t |', '| \t |'],
		[' \t\t\t  x', 'x'],
		[',  \n ', ','],
		['\n -_-  ', '-_-']

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'trim #' + (i + 1),
			function (done) {
				done(Linko.util.trim(opts[0]));
			},
			Linko.test.util.eq(opts[1])
		));
	});

	/* Linko.util.showTime */

	(function () {
		var ms   = 1;
		var sec  = 1000 * ms;
		var min  = 60 * sec;
		var hour = 60 * min;

		[
			[null,                     '--:--'],
			[undefined,                '--:--'],
			['OHAI',                   '--:--'],
			[[],                       '--:--'],
			[{'lol':'wut'},            '--:--'],
			[Number.NaN,               '--:--'],
			[Number.POSITIVE_INFINITY, '--:--'],
			[Number.NEGATIVE_INFINITY, '--:--'],

			[0,        '0:00'],
			[0.01,     '0:00'],
			[sec - ms, '0:00'],
			[sec,      '0:01'],
			[42 * sec, '0:42'],
			[-0.01,       '-0:00'],
			[-(sec - ms), '-0:00'],
			[-sec,        '-0:01'],
			[-(42 * sec), '-0:42'],

			[min - sec,    '0:59'],
			[min + sec,    '1:01'],
			[10 * min - 1, '9:59'],
			[10 * min,     '10:00'],
			[-(min - sec),    '-0:59'],
			[-(min + sec),    '-1:01'],
			[-(10 * min - 1), '-9:59'],
			[-(10 * min),     '-10:00'],

			[hour,             '1:00:00'],
			[hour + min,       '1:01:00'],
			[hour + sec,       '1:00:01'],
			[hour + min + sec, '1:01:01'],
			[9000 * hour,      '9000:00:00'],
			[-hour,               '-1:00:00'],
			[-(hour + min),       '-1:01:00'],
			[-(hour + sec),       '-1:00:01'],
			[-(hour + min + sec), '-1:01:01'],
			[-(9000 * hour),      '-9000:00:00']

		].forEach(function (opts, i) {
			suite.addTestCase(new Linko.test.TestCase(
				'showTime #' + (i + 1),
				function (done) {
					done(Linko.util.showTime(opts[0]));
				},
				Linko.test.util.eq(opts[1])
			));
		});
	})();

	/* Linko.util.showBytes */

	[
		// decimalPlaces

		[1.61803398874989484820, 0, null, null, '2 B'],
		[1.61803398874989484820, 1, null, null, '1.6 B'],
		[1.61803398874989484820, 2, null, null, '1.62 B'],
		[1.61803398874989484820, 3, null, null, '1.618 B'],
		[1.61803398874989484820, 4, null, null, '1.6180 B'],
		[1.61803398874989484820, 5, null, null, '1.61803 B'],
		[1.61803398874989484820, 6, null, null, '1.618034 B'],
		[1.61803398874989484820, 7, null, null, '1.6180340 B'],
		[1.61803398874989484820, 8, null, null, '1.61803399 B'],
		[1.61803398874989484820, 9, null, null, '1.618033989 B'],

		// step

		[1000000000,     2, 10, null, '10.00 YB'],
		[ 100000000,     2, 10, null, '1.00 YB'],
		[  10000000,     2, 10, null, '1.00 ZB'],
		[   1000000,     2, 10, null, '1.00 EB'],
		[    100000,     2, 10, null, '1.00 PB'],
		[     10000,     2, 10, null, '1.00 TB'],
		[      1000,     2, 10, null, '1.00 GB'],
		[       100,     2, 10, null, '1.00 MB'],
		[        10,     2, 10, null, '1.00 KB'],
		[         1,     2, 10, null, '1.00 B'],
		[         0.1,   2, 10, null, '0.10 B'],
		[         0.01,  2, 10, null, '0.01 B'],
		[         0.001, 2, 10, null, '0.00 B'],

		[1000, 2, null, null, '0.98 KB'],
		[1000, 2, 1000, null, '1.00 KB'],
		[1024, 2, null, null, '1.00 KB'],
		[1024, 2, 1000, null, '1.02 KB'],
		[1000000, 4, null, null, '0.9537 MB'],
		[1000000, 4, 1000, null, '1.0000 MB'],
		[1048576, 4, null, null, '1.0000 MB'],
		[1048576, 4, 1000, null, '1.0486 MB'],

		[Math.pow(2, 32), 2, Math.pow(2,  0), null, '4294967296.00 YB'],
		[Math.pow(2, 32), 2, Math.pow(2,  1), null, '16777216.00 YB'],
		[Math.pow(2, 32), 2, Math.pow(2,  2), null, '65536.00 YB'],
		[Math.pow(2, 32), 2, Math.pow(2,  3), null, '256.00 YB'],
		[Math.pow(2, 32), 2, Math.pow(2,  4), null, '1.00 YB'],
		[Math.pow(2, 32), 2, Math.pow(2,  5), null, '4.00 EB'],
		[Math.pow(2, 32), 2, Math.pow(2,  6), null, '4.00 PB'],
		[Math.pow(2, 32), 2, Math.pow(2,  7), null, '16.00 TB'],
		[Math.pow(2, 32), 2, Math.pow(2,  8), null, '1.00 TB'],
		[Math.pow(2, 32), 2, Math.pow(2,  9), null, '32.00 GB'],
		[Math.pow(2, 32), 2, Math.pow(2, 10), null, '4.00 GB'],
		[Math.pow(2, 32), 2, Math.pow(2, 11), null, '1024.00 MB'],
		[Math.pow(2, 32), 2, Math.pow(2, 12), null, '256.00 MB'],
		[Math.pow(2, 32), 2, Math.pow(2, 13), null, '64.00 MB'],
		[Math.pow(2, 32), 2, Math.pow(2, 14), null, '16.00 MB'],
		[Math.pow(2, 32), 2, Math.pow(2, 15), null, '4.00 MB'],
		[Math.pow(2, 32), 2, Math.pow(2, 16), null, '1.00 MB'],
		[Math.pow(2, 32), 2, Math.pow(2, 17), null, '32768.00 KB'],
		[Math.pow(2, 32), 2, Math.pow(2, 18), null, '16384.00 KB'],
		[Math.pow(2, 32), 2, Math.pow(2, 19), null, '8192.00 KB'],
		[Math.pow(2, 32), 2, Math.pow(2, 20), null, '4096.00 KB'],
		[Math.pow(2, 32), 2, Math.pow(2, 21), null, '2048.00 KB'],
		[Math.pow(2, 32), 2, Math.pow(2, 22), null, '1024.00 KB'],
		[Math.pow(2, 32), 2, Math.pow(2, 23), null, '512.00 KB'],
		[Math.pow(2, 32), 2, Math.pow(2, 24), null, '256.00 KB'],
		[Math.pow(2, 32), 2, Math.pow(2, 25), null, '128.00 KB'],
		[Math.pow(2, 32), 2, Math.pow(2, 26), null, '64.00 KB'],
		[Math.pow(2, 32), 2, Math.pow(2, 27), null, '32.00 KB'],
		[Math.pow(2, 32), 2, Math.pow(2, 28), null, '16.00 KB'],
		[Math.pow(2, 32), 2, Math.pow(2, 29), null, '8.00 KB'],
		[Math.pow(2, 32), 2, Math.pow(2, 30), null, '4.00 KB'],
		[Math.pow(2, 32), 2, Math.pow(2, 31), null, '2.00 KB'],
		[Math.pow(2, 32), 2, Math.pow(2, 32), null, '1.00 KB'],
		[Math.pow(2, 32), 2, Math.pow(2, 33), null, '4294967296.00 B'],
		[Math.pow(2, 32), 2, Math.pow(2, 34), null, '4294967296.00 B'],
		[Math.pow(2, 32), 2, Math.pow(2, 35), null, '4294967296.00 B'],

		// threshold

		[1000, 2, 1000, 0.99, '1.00 KB'],
		[1000, 2, 1000, 1.0,  '1.00 KB'],
		[1000, 2, 1000, 2.0,  '1000.00 B'],
		[1500, 2, 1000, 2.0,  '1500.00 B'],
		[2000, 2, 1000, 2.0,  '2.00 KB']

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'showBytes #' + (i + 1),
			function (done) {
				done(Linko.util.showBytes(opts[0], opts[1], opts[2], opts[3]));
			},
			Linko.test.util.eq(opts[4])
		));
	});

	/* Linko.util.hash */

	[
		['', null, 0],
		['asdfasdfasdf', 1337, 2144543143],
		['Has anyone really been far as decided to use even go want to do look more like?', null, 1969611573]

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'hash #' + (i + 1),
			function (done) {
				done(Linko.util.hash(opts[0], opts[1]));
			},
			Linko.test.util.eq(opts[2])
		));
	});

	/* Linko.util.compareVersions */

	[
		['1.0',       '1.0',       0],
		['0',         '0',         0],
		['0.1',       '0',         1],
		['0',         '0.1',      -1],
		['0.0.0.0.1', '0.0.1',    -1],
		['1.9.9.9',   '2.0',      -1],
		['2.3.4.5',   '2.3.4.5',   0],
		['10.20.30',  '10.19.99',  1]

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'compareVersions #' + (i + 1),
			function (done) {
				var res = Linko.util.compareVersions(opts[0], opts[1]);
				done(res < 0 ? -1 : res === 0 ? 0 : 1);
			},
			Linko.test.util.eq(opts[2])
		));
	});

	/* Linko.util.getMediaType */

	[
		// mimeType

		['application/json', null, 'unknown'],
		['lol/cat',          null, 'unknown'],
		['text/html',        null, 'unknown'],

		['image',     null, 'image'],
		['image/png', null, 'image'],
		['image/gif', null, 'image'],
		['image/bmp', null, 'image'],

		['audio',      null, 'audio'],
		['audio/midi', null, 'audio'],
		['audio/mpeg', null, 'audio'],
		['audio/wav',  null, 'audio'],

		['video',           null, 'video'],
		['video/avi',       null, 'video'],
		['video/mpeg',      null, 'video'],
		['video/quicktime', null, 'video'],

		// name

		[null, '3.14159265',   'unknown'],
		[null, 'noexthere',    'unknown'],
		[null, 'not.your.mom', 'unknown'],

		[null, 'c.a.t.png',      'image'],
		[null, 'lever.låda.gif', 'image'],
		[null, 'stuffs.jpg',     'image'],

		[null, 'meow.3gp', 'video'],
		[null, 'cube.mov', 'video'],
		[null, '.gif.mpg', 'video'],

		// special cases

		[null,    null,          'unknown'],
		['image', 'fake.avi',    'image'],
		['audio', 'jpg.jpg.jpg', 'audio'],
		['video', 'html.gif',    'video']

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'getMediaType #' + (i + 1),
			function (done) {
				done(Linko.util.getMediaType({ 'mimeType':opts[0], 'name':opts[1] }));
			},
			Linko.test.util.eq(opts[2])
		));
	});

	/* Linko.util.try_ */

	[
		function (done) {
			var catch_   = false;
			var finally_ = false;
			var ok       = false;
			Linko.util.try_(
				Linko.noop,
				function () {
					catch_ = true;
				},
				function (ok_) {
					ok       = ok_;
					finally_ = true;
				}
			);
			done(!catch_ && finally_ && ok === true);
		},
		function (done) {
			var threw    = false;
			var catch_   = false;
			var finally_ = false;
			var ok       = false;
			try {
				Linko.util.try_(
					function () {
						throw 42;
					},
					function () {
						catch_ = true;
					},
					function (ok_) {
						finally_ = true;
						ok       = ok_;
					}
				);
			}
			catch (e) {
				threw = e;
			}
			setTimeout(function () {
				done(catch_ && finally_ && ok === false && threw === 42);
			}, 500);
		}
	].forEach(function (f, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'try_ #' + (i + 1),
			f,
			Linko.test.util.eq(true)
		));
	});

	/* Linko.util.flatten */

	[
		[null, []],
		[42, []],
		[[1,2,3], [1,2,3]],
		[[[[[[]]]]], []],
		[[[[[[1,[[[2,[[[3]]]]]]]]]]], [1,2,3]],
		[[[[],[],[1],[[[2]]],[[[[[]]]]]],3], [1,2,3]]

	].forEach(function (opts, i) {
		suite.addTestCase(new Linko.test.TestCase(
			'flatten #' + (i + 1),
			function (done) {
				done(Linko.util.flatten(opts[0]));
			},
			Linko.test.util.eq(opts[1])
		));
	});

	/* Linko.util.once */

	suite.addTestCase(new Linko.test.TestCase(
		'once',
		function (done) {
			var count = 0;
			var f = Linko.util.once(function () {
				++count;
			});
			f();
			f();
			f();
			f();
			done(count === 1);
		},
		Linko.test.util.eq(true)
	));

	return suite;
})();

// Linko.pl - end of file 'test/suites/util.js'
// Linko.pl - start of file 'test/suites/XMLHttpRequestImpl.js'

Linko.test.suites['Linko.XMLHttpRequestImpl'] = (function () {
	var suite = new Linko.test.TestSuite('Linko.XMLHttpRequestImpl');
	var testURL = 'http://laire.fi/t/Linko/';

	suite.addTestCase(new Linko.test.TestCase(
		'Synchronous GET',
		function (done) {
			var responses = [];
			var xhr;

			xhr = new Linko.XMLHttpRequestImpl();
			xhr.open('GET', testURL + 'get.pl', false);
			xhr.send();
			responses.push(xhr.getResponseText());

			xhr = new Linko.XMLHttpRequestImpl();
			xhr.open('GET', testURL + 'get.pl?param=5', false);
			xhr.send();
			responses.push(xhr.getResponseText());

			done(responses);
		},
		Linko.test.util.eq(['ERROR\n', 'OK: 25\n'])
	));

	suite.addTestCase(new Linko.test.TestCase(
		'Synchronous POST',
		function (done) {
			var responses = [];
			var xhr;

			xhr = new Linko.XMLHttpRequestImpl();
			xhr.open('POST', testURL + 'post.pl', false);
			xhr.send();
			responses.push(xhr.getResponseText());

			xhr = new Linko.XMLHttpRequestImpl();
			xhr.open('POST', testURL + 'post.pl', false);
			xhr.send('param=5');
			responses.push(xhr.getResponseText());

			done(responses);
		},
		Linko.test.util.eq(['ERROR\n', 'OK: 25\n'])
	));

	suite.addTestCase(new Linko.test.TestCase(
		'Asynchronous GET',
		function (done) {
			var responses = [];
			xhr = new Linko.XMLHttpRequestImpl();
			xhr.open('GET', testURL + 'get.pl?param=5', true);
			xhr.onreadystatechange = function () {
				if (xhr.getReadyState() === 4) {
					done(xhr.getResponseText());
				}
			};
			xhr.send();
		},
		Linko.test.util.eq('OK: 25\n')
	));

	suite.addTestCase(new Linko.test.TestCase(
		'onreadystatechange',
		function (done) {
			var readyStates = [];
			var xhr = new Linko.XMLHttpRequestImpl();
			xhr.open('GET', testURL + 'get.pl?param=5', true);
			xhr.onreadystatechange = function () {
				readyStates.push(xhr.getReadyState());
				if (readyStates.length === 4) {
					done(readyStates);
				}
			};
			setTimeout(function () {
				done(readyStates);
			}, 5000);
			xhr.send();
		},
		Linko.test.util.eq([1,2,3,4]),
		'Plugin fails'
	));

	suite.addTestCase(new Linko.test.TestCase(
		'HTTP status',
		function (done) {
			var readyStates = [];
			var xhr = new Linko.XMLHttpRequestImpl();
			xhr.open('GET', testURL + 'get.pl?param=5', true);
			xhr.onreadystatechange = function () {
				readyStates.push([xhr.getReadyState(), xhr.getStatus()]);
				if (readyStates.length === 4) {
					done(readyStates);
				}
			};
			setTimeout(function () {
				done(readyStates);
			}, 5000);
			xhr.send();
		},
		Linko.test.util.eq([[1,0],[2,200],[3,200],[4,200]]),
		'Plugin fails'
	));

	return suite;
})();

// Linko.pl - end of file 'test/suites/XMLHttpRequestImpl.js'
// Linko.pl - start of file 'WebForm.js'

Linko.WebForm = (function () {
	var getUniqueId = (function () {
		var next = 1000001;
		return function () {
			return next++;
		};
	})();

	var logger = Linko.log.getLogger('Linko.WebForm');

	var WebForm = function (impl) {
		if (!impl) {
			impl = Linko.system.create('WebForm');
		}

		if (!impl) {
			logger.error('constructor: no impl');
			return null;
		}

		this.impl = impl;
	};

	WebForm.logger = logger;

	WebForm.prototype.open = function (url) {
		var taskId = getUniqueId();
		try {
			this.impl.AttachEventHandler('onProgress', function (a, b, c, d) {
				//logger.trace('onProgress:', Linko.util.array(arguments));
				return Linko.taskMonitor.uploadProgress(a, b, c, d, taskId);
			});

			var self = this;
			this.impl.AttachEventHandler('onreadyStateChange', function (readyState) {
				//logger.trace('onreadyStateChange:', Linko.util.array(arguments), [self.getReadyState(), self.getStatus(), self.getResponseText()]);
				try {
					if (readyState === 4 && self.downloadFile && self.downloadFile.end) {
						self.downloadFile.end(self);
					}
				}
				finally {
					Linko.taskMonitor.uploadReadyStateChange(readyState, taskId);
				}
			});

			this.url = url;
			this.taskId = taskId;
			this.impl.Open(url, '', '');
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'open');
		}
		return false;
	};

	WebForm.prototype.addField = function (name, value) {
		try {
			this.impl.AddField(name, value);
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'addField');
		}
		return false;
	};

	/**
	 * @param {String} id
	 * @param {String} src
	 * @param {String} filename
	 * @param {String} mimeType
	 */
	WebForm.prototype.addFile = function (id, src, filename, mimeType) {
		// TODO: What should the source device be if there are multiple files?
		this.src   = src;
		this.title = filename;
		try {
			this.impl.AddFile(id, src, filename, mimeType);
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'addFile');
		}
		return false;
	};

	/**
	 * @param {Object} [options] You can associate some information with the transfer object by giving it here
	 * @param {String} [options.title] A title for the transfer, can be displayed next to the progress bar
	 * @param {String} [options.sourceDeviceId] The source device's ID
	 * @param {String} [options.targetDeviceId] The target device's ID
	 */
	WebForm.prototype.submit = function (args) {
		args = Linko.util.extend({}, args);

		/*
		 * transfer: {
		 *     taskId:     Number
		 *     title:      String  // Can be anything
		 *     state:      String  // pending|active|canceled|failed|done
		 *     bunchId:    Number
		 *     bunchTitle: String
		 *     type:       String  // upload|file|playlist|siteload
		 *     percent:    Number  // 0 <= percent <= 100
		 *     source:     String  // device Id or domain
		 *     target:     String  // device Id
		 * }
		 */

		var bunchId = Linko.downloader.getNewBunchId();
		if (!args.sourceDeviceId && this.src) {
			args.sourceDeviceId = Linko.compoundDB.getDeviceIdByFileId(this.src);
		}

		Linko.taskMonitor.uploadStart({
			'taskId'    : this.taskId,
			// this object will be Linko.util.copy()'d later and this.impl doesn't like it
			//'webForm'   : this,
			'title'     : args.title || this.title,
			'state'     : 'pending',
			'bunchId'   : bunchId,
			'bunchTitle': args.title || this.title,
			'type'      : 'upload',
			'percent'   : 0,
			'source'    : args.sourceDeviceId,
			'target'    : args.targetDeviceId || this.url
		});

		try {
			this.impl.Submit(true); // always async
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'submit');
		}
		return false;
	};

	WebForm.prototype.getReadyState = function () {
		try {
			return this.impl.readyState;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getReadyState');
		}
		return null;
	};

	WebForm.prototype.getStatus = function () {
		try {
			return this.impl.status;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getStatus');
		}
		return null;
	};

	WebForm.prototype.getStatusText = function () {
		try {
			return this.impl.statusText;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getStatusText');
		}
		return null;
	};

	WebForm.prototype.getTimeout = function () {
		try {
			return this.impl.timeout;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getTimeout');
		}
		return null;
	};

	WebForm.prototype.setTimeout = function (msTime) {
		try {
			this.impl.timeout = msTime;
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'setTimeout');
		}
		return false;
	};

	WebForm.prototype.getResponseText = function () {
		//if (this.getReadyState() !== 4) {
		//	logger.debug('getResponseText: ready state (' + this.getReadyState() + ') is not 4, will not access impl.responseText');
		//	return null;
		//}
		//logger.debug('getResponseText: ready state is 4, accessing impl.responseText');
		try {
			return this.impl.responseText;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getResponseText');
		}
		return null;
	};

	WebForm.prototype.getResponseXML = function () {
		//if (this.getReadyState() !== 4) {
		//	return null;
		//}
		try {
			return this.impl.responseXML;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getResponseXML');
		}
		return null;
	};

	WebForm.prototype.setHeader = function (name, value) {
		try {
			this.impl.SetHeader(name, value);
			return true;
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'setHeader');
		}
		return false;
	};

	WebForm.prototype.getResponseHeader = function (header) {
		try {
			return this.impl.GetResponseHeader(header);
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getResponseHeader');
		}
		return null;
	};

	WebForm.prototype.getAllResponseHeaders = function () {
		try {
			return this.impl.GetAllResponseHeaders();
		}
		catch (e) {
			Linko.system.pluginError(e, logger, 'getAllResponseHeaders');
		}
		return null;
	};

	return WebForm;
})();

// Linko.pl - end of file 'WebForm.js'
// Linko.pl - start of file 'XMLHttpRequestImpl.js'

/**
 * Linko.XMLHttpRequestImpl is a minimal wrapper around the plugin's implementation.
 * It is meant to be used only internally and in testing; Linko.XMLHttpRequest tries
 * to handle known bugs and is easier to use.
 */

Linko.XMLHttpRequestImpl = (function () {
	var logger = Linko.log.getLogger('Linko.XMLHttpRequestImpl');

	var XMLHttpRequestImpl = function (impl) {
		if (!impl) {
			impl = Linko.system.create('XMLHTTPRequestEX');
		}

		if (!impl) {
			logger.error('constructor: no impl');
			return null;
		}

		this.impl = impl;

		var self = this;
		this.impl.AttachEventHandler('onreadyStateChange', function () {
			logger.trace('onreadyStateChange');
			if (self.onreadystatechange) {
				self.onreadystatechange();
			}
		});
	};

	XMLHttpRequestImpl.logger = logger;

	XMLHttpRequestImpl.prototype.open = function (method, url, async, username, password) {
		this.impl.Open(method, url, !!async, username || '', password || '');
	};

	XMLHttpRequestImpl.prototype.setRequestHeader = function (header, value) {
		this.impl.setRequestHeader(header, value);
	};

	XMLHttpRequestImpl.prototype.getAllResponseHeaders = function () {
		return this.impl.GetAllResponseHeaders();
	};

	XMLHttpRequestImpl.prototype.getResponseHeader = function (name) {
		return this.impl.GetResponseHeader(name);
	};

	XMLHttpRequestImpl.prototype.send = function (data) {
		if (data) {
			this.impl.send(data);
		}
		else {
			this.impl.send();
		}
	};

	XMLHttpRequestImpl.prototype.abort = function () {
		this.impl[Linko.system.isMac ? 'abort' : 'Abort']();
	};

	XMLHttpRequestImpl.prototype.getResponseText = function () {
		return Linko.system.isMac ? this.impl.responseText() : this.impl.responseText;
	};

	XMLHttpRequestImpl.prototype.getResponseXML = function () {
		return this.impl.responseXML;
	};

	XMLHttpRequestImpl.prototype.getStatus = function () {
		return Linko.system.isMac ? this.impl.status() : this.impl.status;
	};

	XMLHttpRequestImpl.prototype.getReadyState = function () {
		return Linko.system.isMac ? this.impl.readystate() : this.impl.readyState;
	};

	XMLHttpRequestImpl.prototype.getStatusText = function () {
		return this.impl.statusText;
	};

	return XMLHttpRequestImpl;
})();

// Linko.pl - end of file 'XMLHttpRequestImpl.js'
// Linko.pl - start of file 'XMLHttpRequest2.js'

Linko.XMLHttpRequest = (function () {
	var logger = Linko.log.getLogger('Linko.XMLHttpRequest');
	var XMLHttpRequest = function (impl) {
		if (!impl) {
			impl = new Linko.XMLHttpRequestImpl();
		}

		if (!impl) {
			logger.error('constructor: no impl');
			return null;
		}

		this.impl = impl;

		this._readyState = 0;
		var self = this;
		impl.onreadystatechange = function () {
			if (!self.onreadystatechange) {
				return;
			}

			var readyState = self.impl.getReadyState();

			while (self._readyState < readyState) {
				++self._readyState;
				self.onreadystatechange();
			}
		};
	};

	XMLHttpRequest.logger = logger;

	XMLHttpRequest.prototype.open = function (method, url, async, username, password) {
		this.method = method;
		this.async  = async;
		this.impl.open(method, url, async, username || '', password || '');
	};

	XMLHttpRequest.prototype.setRequestHeader = function (header, value) {
		this.impl.setRequestHeader(header, value);
	};

	XMLHttpRequest.prototype.getAllResponseHeaders = function () {
		return this.impl.getAllResponseHeaders();
	};

	XMLHttpRequest.prototype.getResponseHeader = function (name) {
		return this.impl.getResponseHeader(name);
	};

	XMLHttpRequest.prototype.send = function (data) {
		if (data && (this.method === 'GET' || this.method === 'HEAD')) {
			logger.warn('send: data is ignored when method is ' + this.method);
			data = null;
		}

		this.impl.send(data);
		if (!this.async) {
			this.impl.getReadyState = function () { return 4; };
			this.impl.onreadystatechange();
		}
	};

	XMLHttpRequest.prototype.abort = function () {
		this._readyState = 0;
 		this.impl.abort();
	};

	XMLHttpRequest.prototype.getResponseText = function () {
		return this.impl.getResponseText();
	};

	XMLHttpRequest.prototype.getResponseXML = function () {
		return this.impl.getResponseXML();
	};

	XMLHttpRequest.prototype.getStatus = function () {
		if (this._readyState <= 1) {
			return 0;
		}
		return this.impl.getStatus();
	};

	XMLHttpRequest.prototype.getReadyState = function () {
		return this._readyState;
	};

	XMLHttpRequest.prototype.getStatusText = function () {
		return this.impl.getStatusText();
	};

	return XMLHttpRequest;
})();

// Linko.pl - end of file 'XMLHttpRequest2.js'
// Linko.pl - end of combined file 'Linko-all-3.0.9.js'

