Scripting

Gerbera allows you to customize the structure of how your media is being presented to your renderer. One of the most important features introduced since the version 0.8 (mediatomb) are the virtual containers and virtual items.

Let’s think of possible scenarios:

  • You may want to separate your content by music, photo, video, maybe create a special container with all non playable stuff

  • You may want your music to be sorted by genre, year, artist, album, or maybe by starting letters, so you can more easily find your favorite song when browsing the server

  • You want to have your photos that you took with your favorite digital camera to appear in a special folder, or maybe you even want to separate the photos that you took with flash-on from the ones that you made without flash

  • Your media player does not support video, so you do not even want to see the Video container

  • It’s up to your imagination :)

The scenarios described above and much more can be achieved with the help of an import script.

Gerbera supports a playlist parsing feature, which is also handled by scripting.

How It Works

This section will give you some overview on how virtual objects related to scripting. For more details see Layout.

Note

In order to use the import scripting feature you have to change the attribute <virtual-layout type=.. from builtin to js in config.xml

Note

The sorting of Video and Photo items using the rootpath object is still somewhat experimental and not described here.

UPnP servers usually sort files by artist, album, some servers may just present a view similar to the file system and so on. Most servers have strong limitations on the structure of the virtual containers, they usually offer a predefined layout of data and the user has to live with it.

In Gerbera we try to address this shortcoming by introducing the scriptable virtual object engine. It is designed to be:

  • maximally flexible

  • easily customizable and extendable

  • robust and efficient

We try to achieve these goals by embedding a scripting runtime environment that allows the execution of ECMAScript E5/5.1 conform scripts better known as JavaScript. Gerbera uses duktape scripting engine to run JavaScript.

After an item is added to the PC Directory it is automatically fed as input to the import script. The script then creates one or more virtual items for the given original item. Items created from scripts are always marked virtual.

When the virtual object engine gets notified, the following happens: a javascript object is created mirroring the properties of the item. The object is introduced to the function arguments (script environment) and bound to the (predefined) variable orig. This way a variable orig is always defined for every script invocation and represents the original data of the added item. Then the script is invoked.

Note

In the current implementation, if you modify the script then you will have to restart the server for the new logic to take effect.

Global Variables And Constants

In this section we will introduce the properties of the object that will be processed by the script, as well as functions that are offered by the server.

The Media Object

Each time an item is added to the database the import script is invoked. So, one script invocation processes exactly one non virtual item, and creates a number of virtual items and containers. The original item is made available in the form of the global variable ‘orig’. Additionally, when the object being imported is a playlist, it is made available to the playlist parser script in the form of the global variable ‘playlist’. It is usually a good idea to only read from these variables and to create and only modify local copies.

Note

modifying the properties of the orig object will not propagate the changes to the database, only a call to the addCdsObject() will permanently add the object.

General Properties

Here is a list of properties of an object, you can set them you create a new object or when you modify a copy of the ‘orig’ object.

RW means read/write, i.e. - changes made to that property will be transferred into the database.

RO means, that this is a read only property, any changes made to it will get lost.

orig.objectType

RW

This defines the object type, following types are available:

Key

Description

OBJECT_TYPE_CONTAINER

Object is a container

OBJECT_TYPE_ITEM

Object is an item

OBJECT_TYPE_ITEM_EXTERNAL_URL

Object is a link to a resource on the Internet

orig.title

RW

This is the title of the original object, since the object represents an entry in the PC-Directory, the title will be set to it’s file name. This field corresponds to dc:title in the DIDL-Lite XML.

orig.id

RO

The object ID, make sure to set all refID’s (reference IDs) of your virtual objects to that ID.

orig.parentID

RO

The object ID of the parent container.

orig.searchable

RW

If set to true for a container it can be found by a UPnP search if config option /server/upnp/searchable-container-flag is set to yes.

orig.upnpclass

RW

The UPnP class of the item, this corresponds to upnp:class in the DIDL-Lite XML.

orig.location

RO

Location on disk, given by the absolute path and file name.

orig.theora

RO

This property is a boolean value, it is non zero if the particular item is of type OGG Theora. This is useful to allow proper sorting of media and thus placing OGG Vorbis into the Audio container and OGG Theora into the Video container.

orig.onlineservice

RO

Identifies if the item belongs to an online service and thus has extended properties. Following types are available:

Key

Description

ONLINE_SERVICE_NONE

The item does not belong to an online service and does not have extended properties.

ONLINE_SERVICE_APPLE_TRAILERS

The item belongs to the Apple Trailers service and has extended properties.

orig.mimetype

RW

Mimetype of the object.

orig.meta

R

Array holding the metadata that was extracted from the object (i.e. id3/exif/etc. information). The array only contains the first value if multiple were extracted.

orig.metaData

RW

Array holding the metadata that was extracted from the object (i.e. id3/exif/etc. information). Each value is a list of strings regardless whether the source tag is multi valued or not.

Key

Description

DIDL-Lite XML element

M_TITLE

Extracted title (for example the id3 title if the
object is an mp3 file), if you want that your new
virtual object is displayed under this title you will
have to set obj.title = orig.meta[M_TITLE][0]

dc:title

M_ARTIST

Artist information

upnp:artist

M_ALBUM

Album information

upnp:album

M_DATE

Date, must be in the format of YYYY-MM-DD (required by the UPnP spec)

dc:date

M_GENRE

Genre of the item

upnp:genre

M_DESCRIPTION

Description of the item

dc:description

M_REGION

Region description of the item

upnp:region

M_TRACKNUMBER

Track number of the item

upnp:originalTrackNumber

M_PARTNUMBER

Part number of the item. This contains the disc number for audio tracks.

upnp:episodeSeason

M_CREATOR

Creator of the media

dc:creator

M_AUTHOR

Author of the media

upnp:author

M_DIRECTOR

Director of the media

upnp:director

M_PUBLISHER

Publisher of the media

dc:publisher

M_RATING

Rating of the media

upnp:rating

M_ACTOR

Actor of the media

upnp:actor

M_PRODUCER

Producer of the media

upnp:producer

orig.aux

RO

Array holding the so called auxiliary data. Aux data is metadata that is not part of UPnP, for example - this can be a musical work, its performing artists and their instruments, the name of a personal video collection and the episode-ID of a TV show, a camera model that was used to make a photo, or the information if the photo was taken with or without flash.

Currently aux data can be gathered from taglib, ffmpeg and libexif (see the Import section in the main documentation for more details). So, this array will hold the tags that you specified in your config.xml, allowing you to create your virtual structure according to your liking.

orig.res

RO

Array holding the resources. Resources represent the files attached to the media item. Got the the web UI to see the names of available resources. The names of the first resource (number 0) are stored as they are. Further resources are prepended with <number>-.

Currently resources can be gathered during import process (see the Import section in the main documentation for more details). So, this array will hold further data, allowing you to adjust the virtual structure according to your liking.

orig.playlistOrder

RW

This property is only available if the object is being created by the playlist script. It’s similar to ID3 track number, but is used to set the position of the newly created object inside a parsed playlist container. Usually you will increment the number for each new object that you create while parsing the playlist, thus ensuring that the resulting order is the same as in the original playlist.

Configuration

The configuration from config.xml and values changed via web UI are available in the global dictionary config. The key in the dictionary is the xpath as shown in brackets in the web UI. Array items can be accessed via index, Dictionaries with the key. Complex entries like transcoding are not available. The autoscan entry corresponding to the active object is stored in object_autoscan_id. Examples:

print2("Info", config['/server/name']);
print2("Error", "Empty setting", config['/import/library-options/id3/auxdata/add-data'][0]);
print2("Warning", "Active mapping for", config['/import/layout/path']['Directories']);
print2("Debug", "Checking", config['/import/autoscan/directory'][object_autoscan_id].location);

Constants

Actually there are no such things as constants in JS, so those are actually predefined global variables that are set during JS engine initialization. Do not assign any values to them, otherwise following script invocation will be using wrong values.

UPnP Classes

Media and Container Classes:

Constant

Type

Value

Notes

UPNP_CLASS_CONTAINER

string

object.container

UPNP_CLASS_CONTAINER_MUSIC_ARTIST

string

object.container.person.musicArtist

UPNP_CLASS_CONTAINER_MUSIC_GENRE

string

object.container.genre.musicGenre

UPNP_CLASS_CONTAINER_MUSIC_ALBUM

string

object.container.album.musicAlbum

This container class will be treated by the
server in a special way, all music items in
this container will be sorted by ID3 disk
number and track number.

UPNP_CLASS_PLAYLIST_CONTAINER

string

object.container.playlistContainer

This container class will be treated by the
server in a special way, all items in this
container will be sorted by the number
specified in the playlistOrder property
(this is set when an object is created by
the playlist script).

UPNP_CLASS_ITEM

string

object.item

UPNP_CLASS_AUDIO_ITEM

string

object.item.audioItem

UPNP_CLASS_ITEM_MUSIC_TRACK

string

object.item.audioItem.musicTrack

UPNP_CLASS_AUDIO_BOOK

string

object.item.audioItem.musicTrack

UPNP_CLASS_AUDIO_BROADCAST

string

object.item.audioItem.audioBroadcast

UPNP_CLASS_VIDEO_ITEM

string

object.item.videoItem

UPNP_CLASS_VIDEO_MOVIE

string

object.item.videoItem.movie

UPNP_CLASS_VIDEO_MUSICVIDEOCLIP

string

object.item.videoItem.musicVideoClip

UPNP_CLASS_VIDEO_BROADCAST

string

object.item.videoItem.videoBroadcast

UPNP_CLASS_IMAGE_ITEM

string

object.item.imageItem

UPNP_CLASS_IMAGE_PHOTO

string

object.item.imageItem.photo

Object Types:

Constant

Type

Value

OBJECT_TYPE_CONTAINER

integer

1

OBJECT_TYPE_ITEM

integer

2

OBJECT_TYPE_ITEM_EXTERNAL_URL

integer

8

Box Layout Keys

Provided for convenience in scripts to refer to predefined layout settings which have to be defined in config.xml.

For default Audio Layout:

Constant

Value

BK_audioAllAlbums

Audio/allAlbums

BK_audioAllArtists

Audio/allArtists

BK_audioAll

Audio/allAudio

BK_audioAllComposers

Audio/allComposers

BK_audioAllDirectories

Audio/allDirectories

BK_audioAllGenres

Audio/allGenres

BK_audioAllSongs

Audio/allSongs

BK_audioAllTracks

Audio/allTracks

BK_audioAllYears

Audio/allYears

BK_audioArtistChronology

Audio/artistChronology

BK_audioRoot

Audio/audioRoot

For Audio Layout with initials:

Constant

Value

BK_audioInitialAbc

AudioInitial/abc

BK_audioInitialAllArtistTracks

AudioInitial/allArtistTracks

BK_audioInitialAllBooks

AudioInitial/allBooks

BK_audioInitialAudioBookRoot

AudioInitial/audioBookRoot

For Structured Audio Layout:

Constant

Value

BK_audioStructuredAllAlbums

AudioStructured/allAlbums

BK_audioStructuredAllArtistTracks

AudioStructured/allArtistTracks

BK_audioStructuredAllArtists

AudioStructured/allArtists

BK_audioStructuredAllGenres

AudioStructured/allGenres

BK_audioStructuredAllTracks

AudioStructured/allTracks

BK_audioStructuredAllYears

AudioStructured/allYears

For default Video Layout:

Constant

Value

BK_videoAllDates

Video/allDates

BK_videoAllDirectories

Video/allDirectories

BK_videoAll

Video/allVideo

BK_videoAllYears

Video/allYears

BK_videoRoot

Video/videoRoot

BK_videoUnknown

Video/unknown

For default Photo Layout:

Constant

Value

BK_imageAllDates

Image/allDates

BK_imageAllDirectories

Image/allDirectories

BK_imageAll

Image/allImages

BK_imageAllYears

Image/allYears

BK_imageRoot

Image/imageRoot

BK_imageUnknown

Image/unknown

For default Trailer Layout:

Constant

Value

BK_trailerApple

Trailer/appleTrailers

BK_trailerAllGenres

Trailer/allGenres

BK_trailerAll

Trailer/allTrailers

BK_trailerPostDate

Trailer/postDate

BK_trailerRelDate

Trailer/relDate

BK_trailerRoot

Trailer/trailerRoot

BK_trailerUnknown

Trailer/unknown

For default Playlist Layout:

Constant

Value

BK_playlistAll

Playlist/allPlaylists

BK_playlistAllDirectories

Playlist/allDirectories

BK_playlistRoot

Playlist/playlistRoot

Functions

The server offers various native functions that can be called from the scripts, additionally there are some js helper functions that can be used.

Native Server Functions

The so called native functions are implemented in C++ in the server and can be called from the scripts.

Native Functions Available To All Scripts

The server offers three functions which can be called from within the import and/or the playlist script:

addCdsObject(object, containerId)

Adds the object as a virtual object to the container chain

Arguments:
  • object (object()) – A virtual object that is either a copy of or a reference to ‘orig’

  • containerId (string()) – A string, containing the container id as optained from addContainerTree.

addContainerTree(arr)

Creates a hierarchy of containers

Arguments:
  • arr (array()) – An array of container defintions. It has the object structure as described above.

Returns:

container id formatted for use in addCdsObject

const chain = {
    audio: { title: 'Audio', objectType: OBJECT_TYPE_CONTAINER, upnpclass: UPNP_CLASS_CONTAINER },
    allAudio: { title: 'All Audio', objectType: OBJECT_TYPE_CONTAINER, upnpclass: UPNP_CLASS_CONTAINER }
};
var container = addContainerTree([chain.audio, chain.allAudio]);
addCdsObject(obj, container);
copyObject(originalObject)

This function returns a copy of the virtual object.

Arguments:
  • originalObject (object()) –

Returns:

A copy of the virtual object

print(...)

This function is useful for debugging scripts, it simply prints an info message in the log. It it recommended to use print2 instead to set the message level. The function may be removed in the future.

print2(level, ...)

This function is useful for debugging scripts, it prints a message in the log with the set level.

Arguments:
  • level (string()) –

    Set the output message level. The following case insensitive values for level are allowed:

    1. error: Print an error message

    2. warning: Print a warning message

    3. info: Print an info message

    4. debug: Print a debug message, which is only visible if running with –debug or setting debug-mode=”script” in server section of the config file.

f2i(text)

Converts filesystem charset to internal UTF-8.

Arguments:
  • text (string()) –

The ‘from’ charsets can be defined in the server configuration

m2i(text)

Converts metadata charset to internal UTF-8.

Arguments:
  • text (string()) –

The ‘from’ charsets can be defined in the server configuration

p2i(text)

Converts playlist charset to internal UTF-8.

Arguments:
  • text (string()) –

The ‘from’ charsets can be defined in the server configuration

j2i(text)

Converts js charset to internal UTF-8.

Arguments:
  • text (string()) –

The ‘from’ charsets can be defined in the server configuration

Native Functions Available To The Playlist Script

The following function is only available to the playlist script.

readln()
Returns:

string

This function reads and returns exactly one line of text from the playlist that is currently being processed, end of line is identified by carriage return/line feed characters. Each subsequent call will return the next line, there is no way to go back. The idea is, that you can process your playlist line by line and gather the required information to create new objects which can be added to the database.

Helper Functions

There is a set of helper JavaScript functions which reside in the common.js script. They can be used by the import and by the playlist script.

escapeSlash(name)

Escapes slash ‘/’ characters in a string. This is necessary, because the container chain is defined by a slash separated string, where slash has a special meaning - it defines the container hierarchy. That means, that slashes that appear in the object’s title need to be properly escaped.

Arguments:
  • name (string()) – A string to be escaped

Returns:

string

createContainerChain(arr)

Verifies that the names are properly escaped and adds the slash separators as necessary

Arguments:
  • arr (array()) – An array of container names

Returns:

string formatted for use in addCdsObject

function createContainerChain(arr)
{
    var path = '';
    for (var i = 0; i < arr.length; i++)
    {
        path = path + '/' + escapeSlash(arr[i]);
    }
    return path;
}
mapGenre(genre)

Map value of the genre in virtual layout

Arguments:
  • string – Source value of the genre

Returns:

string - Modified value of the genre

function mapGenre(genre) {
    const genreConfig = config['/import/scripting/virtual-layout/genre-map/genre'];
    if (genreConfig) {
        const genreNames = Object.getOwnPropertyNames(genreConfig);
        for (var idx = 0; idx < genreNames.length; idx++) {
            var re = new RegExp('(' + genreNames[idx] + ')', 'i');
            var match = re.exec(genre);
            if (match) {
                genre = genreConfig[genreNames[idx]];
                break;
            }
        }
    }
    return genre;
}
mapInitial(firstChar)

Map character to uppercase latin version

Arguments:
  • string – Source character

Returns:

string - Modified value of the character

function mapInitial(firstChar) {
    firstChar = firstChar.toUpperCase();
    // map character to latin version
    const charTable = [
        ["ÄÁÀÂÆààáâãäåæ", "A"],
        ["Çç", "C"],
        ["Ðð", "D"],
        ["ÉÈÊÈÉÊËèéêë", "E"],
        ["ÍÌÎÏìíîï", "I"],
        ["Ññ", "N"],
        ["ÕÖÓÒÔØòóôõöøœ", "O"],
        ["Š", "S"],
        ["ÜÚÙÛùúûü", "U"],
        ["Ýýÿ", "Y"],
        ["Žž", "Z"],
    ];
    for (var idx = 0; idx < charTable.length; idx++) {
        var re = new RegExp('[' + charTable[idx][0] + ']', 'i');
        var match = re.exec(firstChar);
        if (match) {
            firstChar = charTable[idx][1];
            break;
        }
    }
    return firstChar;
}
intFromConfig(entry, defValue)

Read integer config value or use default

Arguments:
  • entry (string()) – Config Entry

  • defValue (int()) – Config Entry

Returns:

int - value from config or default

function intFromConfig(entry, defValue) {
    if (entry in config) {
        var value = config[entry];
        return parseInt(value.toString());
    }
    return defValue;
}
stringFromConfig(entry, defValue)

Read string config value or use default

Arguments:
  • entry (string()) – Config Entry

  • defValue (string()) – Config Entry

Returns:

string - value from config or default

function stringFromConfig(entry, defValue) {
    if (entry in config) {
        var value = config[entry];
        return value.toString();
    }
    return defValue;
}
getYear(date)
Arguments:
  • string – A date formatted in yyyy-mm-dd

Returns:

string - Year value

function getYear(date)
{
    var matches = date.match(/^([0-9]{4})-/);
    if (matches)
        return matches[1];
    else
        return date;
}
getPlaylistType(mimetype)

This function identifies the type of the playlist by the mimetype, it is used in the playlist script to select an appropriate parser.

Arguments:
  • string – A valid mime-type

Returns:

string - playlist type

function getPlaylistType(mimetype)
{
    if (mimetype == 'audio/x-mpegurl')
        return 'm3u';
    if (mimetype == 'audio/x-scpls')
        return 'pls';
    return '';
}

Walkthrough

Now it is time to take a closer look at the default scripts that are supplied with Gerbera. Usually it is installed in the /usr/share/gerbera/js/ directory, but you will also find it in scripts/js/ in the Gerbera source tree.

On startup gerbera loads all the javascript files from that directory (common-folder in config.xml) then loads all files from custom-folder. If a function defined in a common file is also found in the custom folder it overwrites it.

You can still use the former mechanism of defining common-script and custom-script in config.xml which works as follows: The functions are defined in common.js and can easily be overwritten in a file which is set in custom-script in config.xml. Compared to former versions of gerbera you do not have to copy the whole file to overwrite one function.

Note

this is not a JavaScript tutorial, if you are new to JS you should probably make yourself familiar with the language.

Import Logic

We start with a walkthrough of the default import script, it is called import.js in the Gerbera distribution.

Below are the import script functions that organize our content in the database by creating the virtual structure. Each media type - audio, image and video is handled by a separate function.

The entry point of each import function has the following synopsis

importFile(obj, cont, rootPath, containerType)

Create virtual layout for a specific file type

Arguments:
  • obj (object()) – Original object as created in PC Directory

  • cont (object()) – Container in PC Directory containing the original object

  • rootPath (string()) – Root folder of the autoscan directoy

  • containerType (string()) – UPnP type configured to create containers

Returns:

nothing

Each entry function is a wrapper around the common function that contains the logic

function importAudio(obj, cont, rootPath, autoscanId, containerType) {
    addAudio(obj, cont, rootPath, containerType);
}
function importAudioStructured(obj, cont, rootPath, autoscanId, containerType) {
    addAudioStructured(obj, cont, rootPath, containerType);
}
function importVideo(obj, cont, rootPath, autoscanId, containerType) {
    addVideo(obj, cont, rootPath, containerType);
}
function importImage(obj, cont, rootPath, autoscanId, containerType) {
    addImage(obj, cont, rootPath, containerType);
}
function importTrailer(obj, cont, rootPath, autoscanId, containerType) {
    addTrailer(obj);
}

Audio Content Handler

The biggest one is the function that handles audio - the reason is simple: flac and mp3 files offer a lot of metadata like album, artist, genre, etc. information, this allows us to create a nice container layout.

function addAudio(obj, cont, rootPath, containerType) {
    // Note the difference between obj.title and obj.metaData[M_TITLE] -
    // while object.title will originally be set to the file name,
    // obj.metaData[M_TITLE] will contain the parsed title - in this
    // particular example the ID3 title of an MP3.
    var title = obj.title;

    // First we will gather all the metadata that is provided by our
    // object, of course it is possible that some fields are empty -
    // we will have to check that to make sure that we handle this
    // case correctly.
    if (obj.metaData[M_TITLE] && obj.metaData[M_TITLE][0]) {
        title = obj.metaData[M_TITLE][0];
    }

    var desc = '';
    var artist = [ 'Unknown' ];
    var artist_full = null;
    if (obj.metaData[M_ARTIST] && obj.metaData[M_ARTIST][0]) {
        artist = obj.metaData[M_ARTIST];
        artist_full = artist.join(' / ');
        desc = artist_full;
    }

    var album = 'Unknown';
    var album_full = null;
    if (obj.metaData[M_ALBUM] && obj.metaData[M_ALBUM][0]) {
        album = obj.metaData[M_ALBUM][0];
        desc = desc + ', ' + album;
        album_full = album;
    }

    if (desc) {
        desc = desc + ', ';
    }
    desc = desc + title;

    var date = 'Unknown';
    if (obj.metaData[M_DATE] && obj.metaData[M_DATE][0]) {
        date = getYear(obj.metaData[M_DATE][0]);
        obj.metaData[M_UPNP_DATE] = [ date ];
        desc = desc + ', ' + date;
    }

    var genre = 'Unknown';
    if (obj.metaData[M_GENRE] && obj.metaData[M_GENRE][0]) {
        genre = mapGenre(obj.metaData[M_GENRE].join(' '));
        desc = desc + ', ' + genre;
    }

    if (!obj.metaData[M_DESCRIPTION] || !obj.metaData[M_DESCRIPTION][0]) {
        obj.description = desc;
    }

    var composer = 'None';
    if (obj.metaData[M_COMPOSER] && obj.metaData[M_COMPOSER][0]) {
        composer = obj.metaData[M_COMPOSER].join(' / ');
    }

/*
    var conductor = 'None';
    if (obj.metaData[M_CONDUCTOR] && obj.metaData[M_CONDUCTOR][0]) {
        conductor = obj.metaData[M_CONDUCTOR].join(' / ');
    }

    var orchestra = 'None';
    if (obj.metaData[M_ORCHESTRA] && obj.metaData[M_ORCHESTRA][0]) {
        orchestra = obj.metaData[M_ORCHESTRA].join(' / ');
    }
*/
    // uncomment this if you want to have track numbers in front of the title
    // in album view
/*
    var track = '';
    if (obj.trackNumber > 0) {
        track = trackNumber;
        if (track.length === 1) {
            track = '0' + track;
        }
        track = track + ' ';
    }
*/
    // comment the following line out if you uncomment the stuff above  :)
    var track = '';

// uncomment this if you want to have channel numbers in front of the title
/*
    var channels = obj.res[R_NRAUDIOCHANNELS];
    if (channels) {
        if (channels === "1") {
            track = track + '[MONO]';
        } else if (channels === "2") {
            track = track + '[STEREO]';
        } else {
            track = track + '[MULTI]';
        }
    }
*/

    // We finally gathered all data that we need, so let's create a
    // nice layout for our audio files.

    obj.title = title;

    // The UPnP class argument to addCdsObject() is optional, if it is
    // not supplied the default UPnP class will be used. However, it
    // is suggested to correctly set UPnP classes of containers and
    // objects - this information may be used by some renderers to
    // identify the type of the container and present the content in a
    // different manner.

    // Remember, the server will sort all items by ID3 track if the
    // container class is set to UPNP_CLASS_CONTAINER_MUSIC_ALBUM.

    const parentCount = intFromConfig('/import/resources/container/attribute::parentCount', 1);
    const containerResource = parentCount > 1 ? cont.res : undefined;
    const containerRefID = cont.res.count > 0 ? cont.id : obj.id;
    const boxSetup = config['/import/scripting/virtual-layout/boxlayout/box'];

    const chain = {
        audio: {
            id: boxSetup[BK_audioRoot].id,
            title: boxSetup[BK_audioRoot].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_audioRoot].class,
            metaData: [] },
        allAudio: {
            id: boxSetup[BK_audioAll].id,
            title: boxSetup[BK_audioAll].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_audioAll].class },
        allArtists: {
            id: boxSetup[BK_audioAllArtists].id,
            title: boxSetup[BK_audioAllArtists].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_audioAllArtists].class },
        allGenres: {
            id: boxSetup[BK_audioAllGenres].id,
            title: boxSetup[BK_audioAllGenres].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_audioAllGenres].class },
        allAlbums: {
            id: boxSetup[BK_audioAllAlbums].id,
            title: boxSetup[BK_audioAllAlbums].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_audioAllAlbums].class },
        allYears: {
            id: boxSetup[BK_audioAllYears].id,
            title: boxSetup[BK_audioAllYears].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_audioAllYears].class },
        allComposers: {
            id: boxSetup[BK_audioAllComposers].id,
            title: boxSetup[BK_audioAllComposers].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_audioAllComposers].class },
        allSongs: {
            id: boxSetup[BK_audioAllSongs].id,
            title: boxSetup[BK_audioAllSongs].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_audioAllSongs].class },
        allFull: {
            id: boxSetup[BK_audioAllTracks].id,
            title: boxSetup[BK_audioAllTracks].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_audioAllTracks].class },
        artistChronology: {
            id: boxSetup[BK_audioArtistChronology].id,
            title: boxSetup[BK_audioArtistChronology].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_audioArtistChronology].class },

        artist: {
            searchable: false,
            title: artist[0],
            location: artist[0],
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: UPNP_CLASS_CONTAINER_MUSIC_ARTIST,
            metaData: [],
            res: containerResource,
            aux: obj.aux,
            refID: containerRefID },
        album: {
            searchable: false,
            title: album,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: UPNP_CLASS_CONTAINER_MUSIC_ALBUM,
            metaData: [],
            res: containerResource,
            aux: obj.aux,
            refID: containerRefID },
        genre: {
            title: genre,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: UPNP_CLASS_CONTAINER_MUSIC_GENRE,
            metaData: [],
            res: containerResource,
            aux: obj.aux,
            refID: containerRefID },
        year: {
            title: date,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: UPNP_CLASS_CONTAINER,
            metaData: [],
            res: containerResource,
            aux: obj.aux,
            refID: containerRefID },
        composer: {
            title: composer,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: UPNP_CLASS_CONTAINER_MUSIC_COMPOSER,
            metaData: [],
            res: containerResource,
            aux: obj.aux,
            refID: containerRefID },
    };

    chain.audio.metaData[M_CONTENT_CLASS] = [ UPNP_CLASS_AUDIO_ITEM ];
    chain.album.metaData[M_ARTIST] = artist;
    chain.album.metaData[M_ALBUMARTIST] = artist;
    chain.album.metaData[M_GENRE] = [ genre ];
    chain.album.metaData[M_DATE] = obj.metaData[M_DATE];
    chain.album.metaData[M_UPNP_DATE] = obj.metaData[M_UPNP_DATE];
    chain.album.metaData[M_ALBUM] = [ album ];
    chain.artist.metaData[M_ARTIST] = artist;
    chain.artist.metaData[M_ALBUMARTIST] = artist;
    chain.genre.metaData[M_GENRE] = [ genre ];
    chain.year.metaData[M_DATE] = [ date ];
    chain.year.metaData[M_UPNP_DATE] = [ date ];
    chain.composer.metaData[M_COMPOSER] = [ composer ];

    var container;
    if (boxSetup[BK_audioAll].enabled) {
        container = addContainerTree([chain.audio, chain.allAudio]);
        addCdsObject(obj, container);
    }

    if (boxSetup[BK_audioAllSongs].enabled && boxSetup[BK_audioAllArtists].enabled) {
        container = addContainerTree([chain.audio, chain.allArtists, chain.artist, chain.allSongs]);
        addCdsObject(obj, container);
    }

    var i;
    const artCnt = artist.length;
    if (boxSetup[BK_audioAllTracks].enabled) {
        var temp = obj.title; //Backup the object title
        var prefix = '';
        if (artist_full) {
            prefix = artist_full;
        }

        if (album_full) {
            prefix = prefix + ' - ' + album_full + ' - ';
        } else {
            prefix = prefix + ' - ';
        }

        obj.title = prefix + title;
        container = addContainerTree([chain.audio, chain.allFull]);
        addCdsObject(obj, container);

        if (boxSetup[BK_audioAllArtists].enabled) {
            for (i = 0; i < artCnt; i++) {
                chain.artist.title = artist[i];
                container = addContainerTree([chain.audio, chain.allArtists, chain.artist, chain.allFull]);
                addCdsObject(obj, container);
            }
        }

        obj.title = temp; // Restore the title
    }

    if (boxSetup[BK_audioAllArtists].enabled) {
        var temp = obj.title; //Backup the object title
        obj.title = track + title;

        for (i = 0; i < artCnt; i++) {
            chain.artist.title = artist[i];
            chain.artist.searchable = true;
            container = addContainerTree([chain.audio, chain.allArtists, chain.artist, chain.album]);
            addCdsObject(obj, container);
        }

        obj.title = temp; // Restore the title
    }

    if (boxSetup[BK_audioAllAlbums].enabled) {
        chain.album.searchable = true;
        var temp = obj.title; //Backup the object title
        obj.title = track + title;

        chain.album.location = getRootPath(rootPath, obj.location).join('_');
        container = addContainerTree([chain.audio, chain.allAlbums, chain.album]);
        chain.album.location = '';
        addCdsObject(obj, container);

        obj.title = temp; // Restore the title
    }

    if (boxSetup[BK_audioAllGenres].enabled) {
        chain.genre.searchable = true;
        if (obj.metaData[M_GENRE]) {
            for (var oneGenre in obj.metaData[M_GENRE]) {
                chain.genre.title = obj.metaData[M_GENRE][oneGenre];
                chain.genre.metaData[M_GENRE] = [ oneGenre ];
                container = addContainerTree([chain.audio, chain.allGenres, chain.genre]);
                addCdsObject(obj, container);
            }
        }
    }

    if (boxSetup[BK_audioAllYears].enabled) {
        container = addContainerTree([chain.audio, chain.allYears, chain.year]);
        addCdsObject(obj, container);
    }

    if (boxSetup[BK_audioAllComposers].enabled) {
        container = addContainerTree([chain.audio, chain.allComposers, chain.composer]);
        addCdsObject(obj, container);
    }

    if (boxSetup[BK_audioArtistChronology].enabled && boxSetup[BK_audioAllArtists].enabled) {
        chain.album.searchable = false;
        chain.artist.searchable = false;
        chain.album.title = date + " - " + album;
        container = addContainerTree([chain.audio, chain.allArtists, chain.artist, chain.artistChronology, chain.album]);
        addCdsObject(obj, container);

        chain.album.title = album; // Restore the title;
    }
}

Most music file taggers can handle additional metadata that is not part of UPnP, so you could add code to present your music to the renderer by musical works, their different interpretations, and the performing artists.

Note

if you want to use those additional metadata you need to compile Gerbera with taglib support and also specify the fields of interest in the import section of your configuration file (See documentation about library-options).

Image Content Handler

This function takes care of images. Currently it does very little sorting, but could easily be extended - photos made by digital cameras provide lots of information in the Exif tag, so you could easily add code to sort your pictures by camera model or anything Exif field you might be interested in.

Note

if you want to use those additional Exif fields you need to compile Gerbera with libexif support and also specify the fields of interest in the import section of your configuration file (See documentation about library-options).

function addImage(obj, cont, rootPath, containerType) {
    const dir = getRootPath(rootPath, obj.location);
    const parentCount = intFromConfig('/import/resources/container/attribute::parentCount', 1);
    const containerResource = parentCount > 1 ? cont.res : undefined;
    const containerRefID = cont.res.count > 0 ? cont.id : obj.id;
    const boxSetup = config['/import/scripting/virtual-layout/boxlayout/box'];

    const chain = {
        imageRoot: {
            id: boxSetup[BK_imageRoot].id,
            title: boxSetup[BK_imageRoot].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_imageRoot].class, metaData: [] },
        allImages: {
            id: boxSetup[BK_imageAll].id,
            title: boxSetup[BK_imageAll].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_imageAll].class, metaData: [] },
        allDirectories: {
            id: boxSetup[BK_imageAllDirectories].id,
            title: boxSetup[BK_imageAllDirectories].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_imageAllDirectories].class, metaData: [] },
        allYears: {
            id: boxSetup[BK_imageAllYears].id,
            title: boxSetup[BK_imageAllYears].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_imageAllYears].class, metaData: [] },
        allDates: {
            id: boxSetup[BK_imageAllDates].id,
            title: boxSetup[BK_imageAllDates].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_imageAllDates].class, metaData: [] },

        year: {
            title: boxSetup[BK_imageUnknown].title,
            objectType: OBJECT_TYPE_CONTAINER,
            searchable: true,
            upnpclass: UPNP_CLASS_CONTAINER },
        month: {
            title: boxSetup[BK_imageUnknown].title,
            objectType: OBJECT_TYPE_CONTAINER,
            searchable: true,
            upnpclass: UPNP_CLASS_CONTAINER,
            metaData: [],
            res: containerResource,
            aux: obj.aux,
            efID: containerRefID },
        date: {
            title: boxSetup[BK_imageUnknown].title,
            objectType: OBJECT_TYPE_CONTAINER,
            searchable: true,
            upnpclass: UPNP_CLASS_CONTAINER,
            metaData: [],
            res: containerResource,
            aux: obj.aux,
            refID: containerRefID },
    };
    chain.imageRoot.metaData[M_CONTENT_CLASS] = [ UPNP_CLASS_IMAGE_ITEM ];
    addCdsObject(obj, addContainerTree([chain.imageRoot, chain.allImages]));

    // Years
    if (boxSetup[BK_imageAllYears].enabled && obj.metaData[M_DATE] && obj.metaData[M_DATE][0]) {
        var date = obj.metaData[M_DATE][0];
        var dateParts = date.split('-');
        if (dateParts.length > 1) {
            chain.year.title = dateParts[0];
            chain.month.title = dateParts[1];
            addCdsObject(obj, addContainerTree([chain.imageRoot, chain.allYears, chain.year, chain.month]));
        }
    }

    // Dates
    if (boxSetup[BK_imageAllDates].enabled && obj.metaData[M_DATE] && obj.metaData[M_DATE][0]) {
        var date = obj.metaData[M_DATE][0];
        var dateParts = date.split('T');
        if (dateParts.length > 1) {
            date = dateParts[0];
        }
        chain.date.title = date;
        addCdsObject(obj, addContainerTree([chain.imageRoot, chain.allDates, chain.date]));
    }

    // Directories
    if (boxSetup[BK_imageAllDirectories].enabled && dir.length > 0) {
        var tree = [chain.imageRoot, chain.allDirectories];
        for (var i = 0; i < dir.length; i++) {
            tree = tree.concat([{ title: dir[i], objectType: OBJECT_TYPE_CONTAINER, upnpclass: UPNP_CLASS_CONTAINER }]);
        }
        tree[tree.length-1].upnpclass = containerType;
        tree[tree.length-1].metaData = [];
        tree[tree.length-1].res = containerResource;
        tree[tree.length-1].aux = obj.aux;
        tree[tree.length-1].refID = containerRefID;
        addCdsObject(obj, addContainerTree(tree));
    }
}

Just like in the addAudio() function - we construct our container chain and add the object.

Video Content Handler

Not much to say here… I think libextractor is capable of retrieving some information from video files, however I seldom encountered any video files populated with metadata. You could also try ffmpeg to get more information, however by default we keep it very simple - we just put everything into the ‘All Video’ container.

Note

if you want to use additional metadata fields you need to compile Gerbera with ffmpeg support and also specify the fields of interest in the import section of your configuration file (See documentation about library-options).

function addVideo(obj, cont, rootPath, containerType) {
    const dir = getRootPath(rootPath, obj.location);
    const parentCount = intFromConfig('/import/resources/container/attribute::parentCount', 1);
    const containerResource = parentCount > 1 ? cont.res : undefined;
    const containerRefID = cont.res.count > 0 ? cont.id : obj.id;
    const boxSetup = config['/import/scripting/virtual-layout/boxlayout/box'];
    const chain = {
        video: {
            id: boxSetup[BK_videoRoot].id,
            title: boxSetup[BK_videoRoot].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_videoRoot].class, metaData: [] },
        allVideo: {
            id: boxSetup[BK_videoAll].id,
            title: boxSetup[BK_videoAll].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_videoAll].class, metaData: [] },
        allDirectories: {
            id: boxSetup[BK_videoAllDirectories].id,
            title: boxSetup[BK_videoAllDirectories].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_videoAllDirectories].class, metaData: [] },
        allYears: {
            id: boxSetup[BK_videoAllYears].id,
            title: boxSetup[BK_videoAllYears].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_videoAllYears].class, metaData: [] },
        allDates: {
            id: boxSetup[BK_videoAllDates].id,
            title: boxSetup[BK_videoAllDates].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_videoAllDates].class, metaData: [] },

        year: {
            title: boxSetup[BK_videoUnknown].title,
            objectType: OBJECT_TYPE_CONTAINER,
            searchable: true,
            upnpclass: UPNP_CLASS_CONTAINER },
        month: {
            title: boxSetup[BK_videoUnknown].title,
            objectType: OBJECT_TYPE_CONTAINER,
            searchable: true,
            upnpclass: UPNP_CLASS_CONTAINER,
            metaData: [],
            res: containerResource,
            aux: obj.aux,
            refID: containerRefID },
        date: {
            title: boxSetup[BK_videoUnknown].title,
            objectType: OBJECT_TYPE_CONTAINER,
            searchable: true,
            upnpclass: UPNP_CLASS_CONTAINER,
            metaData: [],
            res: containerResource,
            aux: obj.aux,
            refID: containerRefID },
    };
    chain.video.metaData[M_CONTENT_CLASS] = [ UPNP_CLASS_VIDEO_ITEM ];
    var container = addContainerTree([chain.video, chain.allVideo]);
    addCdsObject(obj, container);

    // Year
    if (boxSetup[BK_videoAll].enabled && obj.metaData[M_CREATION_DATE] && obj.metaData[M_CREATION_DATE][0]) {
        var date = obj.metaData[M_CREATION_DATE][0];
        var dateParts = date.split('-');
        if (dateParts.length > 1) {
            chain.year.title = dateParts[0];
            chain.month.title = dateParts[1];
            addCdsObject(obj, addContainerTree([chain.video, chain.allYears, chain.year, chain.month]));
        }
    }

    // Dates
    if (boxSetup[BK_videoAllDates].enabled && obj.metaData[M_CREATION_DATE] && obj.metaData[M_CREATION_DATE][0]) {
        var date = obj.metaData[M_CREATION_DATE][0];
        var dateParts = date.split('T');
        if (dateParts.length > 1) {
            date = dateParts[0];
        }
        chain.date.title = date;
        addCdsObject(obj, addContainerTree([chain.video, chain.allDates, chain.date]));
    }

    // Directories
    if (boxSetup[BK_videoAllDirectories].enabled && dir.length > 0) {
        var tree = [chain.video, chain.allDirectories];
        for (var i = 0; i < dir.length; i++) {
            tree = tree.concat({ title: dir[i], objectType: OBJECT_TYPE_CONTAINER, upnpclass: UPNP_CLASS_CONTAINER });
        }
        tree[tree.length-1].upnpclass = containerType;
        tree[tree.length-1].metaData = [];
        tree[tree.length-1].res = containerResource;
        tree[tree.length-1].aux = obj.aux;
        tree[tree.length-1].refID = containerRefID;
        addCdsObject(obj, addContainerTree(tree));
    }
}

Apple Trailers Content Handler

This function processes items that are imported via the Apple Trailers feature. We will organize the trailers by genre, post date and release date, additionally we will also add a container holding all trailers.

function addTrailer(obj) {
    const boxSetup = config['/import/scripting/virtual-layout/boxlayout/box'];
    const chain = {
        trailerRoot: {
            id: boxSetup[BK_trailerRoot].id,
            title: boxSetup[BK_trailerRoot].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_trailerRoot].class, metaData: [] },
        appleTrailers: {
            id: boxSetup[BK_trailerApple].id,
            title: boxSetup[BK_trailerApple].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_trailerApple].class, metaData: [] },
        allTrailers: {
            id: boxSetup[BK_trailerAll].id,
            title: boxSetup[BK_trailerAll].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_trailerAll].class, metaData: [] },
        allGenres: {
            id: boxSetup[BK_trailerAllGenres].id,
            title: boxSetup[BK_trailerAllGenres].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_trailerAllGenres].class, metaData: [] },
        relDate: {
            id: boxSetup[BK_trailerRelDate].id,
            title: boxSetup[BK_trailerRelDate].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_trailerRelDate].class, metaData: [] },
        postDate: {
            id: boxSetup[BK_trailerPostDate].id,
            title: boxSetup[BK_trailerPostDate].title,
            objectType: OBJECT_TYPE_CONTAINER,
            upnpclass: boxSetup[BK_trailerPostDate].class, metaData: [] },

        genre: {
            title: boxSetup[BK_trailerUnknown].title,
            objectType: OBJECT_TYPE_CONTAINER,
            searchable: true,
            upnpclass: UPNP_CLASS_CONTAINER_MUSIC_GENRE,
            metaData: [] },
        date: {
            title: boxSetup[BK_trailerUnknown].title,
            objectType: OBJECT_TYPE_CONTAINER,
            searchable: true,
            upnpclass: UPNP_CLASS_CONTAINER,
            metaData: [] }
    };
    // First we will add the item to the 'All Trailers' container, so
    // that we get a nice long playlist:

    addCdsObject(obj, addContainerTree([chain.trailerRoot , chain.appleTrailers, chain.allTrailers]));

    // We also want to sort the trailers by genre, however we need to
    // take some extra care here: the genre property here is a comma
    // separated value list, so one trailer can have several matching
    // genres that will be returned as one string. We will split that
    // string and create individual genre containers.

    if (boxSetup[BK_trailerAllGenres].enabled && obj.metaData[M_GENRE] && obj.metaData[M_GENRE][0]) {
        var genre = obj.metaData[M_GENRE][0];

        // A genre string "Science Fiction, Thriller" will be split to
        // "Science Fiction" and "Thriller" respectively.

        const genres = genre.split(', ');
        for (var i = 0; i < genres.length; i++) {
            chain.genre.title = genres[i];
            chain.genre.metaData[M_GENRE] = [ genres[i] ];
            addCdsObject(obj, addContainerTree([chain.trailerRoot , chain.appleTrailers, chain.allGenres, chain.genre]));
        }
    }

    // The release date is offered in a YYYY-MM-DD format, we won't do
    // too much extra checking regading validity, however we only want
    // to group the trailers by year and month:

    if (boxSetup[BK_trailerRelDate].enabled && obj.metaData[M_DATE] && obj.metaData[M_DATE][0]) {
        var reldate = obj.metaData[M_DATE][0];
        if (reldate.length >= 7) {
            chain.date.title = reldate.slice(0, 7);
            chain.date.metaData[M_DATE] = [ reldate.slice(0, 7) ];
            chain.date.metaData[M_UPNP_DATE] = [ reldate.slice(0, 7) ];
            addCdsObject(obj, addContainerTree([chain.trailerRoot , chain.appleTrailers, chain.relDate, chain.date]));
        }
    }

    // We also want to group the trailers by the date when they were
    // originally posted, the post date is available via the aux
    // array. Similar to the release date, we will cut off the day and
    // create our containres in the YYYY-MM format.
    if (boxSetup[BK_trailerPostDate].enabled) {
        var postdate = obj.aux[APPLE_TRAILERS_AUXDATA_POST_DATE];
        if (postdate && postdate.length >= 7) {
            chain.date.title = postdate.slice(0, 7);
            chain.date.metaData[M_DATE] = [ postdate.slice(0, 7) ];
            chain.date.metaData[M_UPNP_DATE] = [ postdate.slice(0, 7) ];
            addCdsObject(obj, addContainerTree([chain.trailerRoot , chain.appleTrailers, chain.postDate, chain.date]));
        }
    }
}

Putting it all together

In the default configuration these functions are registered as entry points so that all parameters are supplied by the runtime

<import-function>
    <audio-file>importAudioKarl</audio-file>
    <video-file>importVideo</video-file>
    <image-file>importImage</image-file>
    <playlist create-link="yes">importPlaylist</playlist>
    <meta-file>importMetadata</meta-file>
    <trailer>importTrailer</trailer>
</import-function>

If you set import-script in config.xml the script is fully evaluated and calls the wrapper to select the correct import function.

var orig;
var cont;
// compatibility with older configurations
if (!cont || cont === undefined)
    cont = orig;
if (orig && orig !== undefined)
    importItem(orig, cont);

Playlist Script

The default playlist parsing script is called playlists.js, similar to the import script it works with a global object which is called ‘playlist’, the fields are similar to the ‘orig’ that is used in the import script with the exception of the playlistOrder field which is special to playlists.

Another big difference between playlist and import scripts is, that playlist scripts can add new media to the database, while import scripts only process already existing objects (the ones found in PC Directory) and just add additional virtual items.

The default playlist script implementation supports parsing of m3u and pls formats, but you can add support for parsing of any ASCII based playlist format.

We will first look at a helper function:

importPlaylist(obj, cont, rootPath, containerType)

It is defined in playlists.js, it receives the location (path on disk or HTTP URL), the title and the desired position of the item in the database layout (remember the container chains used in the import script).

The function first decides if we are dealing with an item that represents a resource on the web, or if we are dealing with a local file. After that it populates all item fields accordingly and calls the addCdsObject() that was introduced earlier. Note, that if the object that is being added by the playlist script is not yet in the database, the import script will be invoked.

Below is the complete function with some comments:

function addPlaylistItem(playlist_title, playlistLocation, entry, playlistChain, playlistOrder) {
    // Determine if the item that we got is an URL or a local file.

    if (entry.location.match(/^.*:\/\//)) {
        var exturl = {};

        // Setting the mimetype is crucial and tricky... if you get it
        // wrong your renderer may show the item as unsupported and refuse
        // to play it. Unfortunately most playlist formats do not provide
        // any mimetype information.
        exturl.mimetype = entry.mimetype ? entry.mimetype : 'audio/mpeg';

        // Make sure to correctly set the object type, then populate the
        // remaining fields.
        exturl.objectType = OBJECT_TYPE_ITEM_EXTERNAL_URL;

        exturl.location = entry.location;
        exturl.title = (entry.title ? entry.title : entry.location);
        exturl.protocol = entry.protocol ? entry.protocol : 'http-get';
        exturl.upnpclass = exturl.mimetype.startsWith('video') ? UPNP_CLASS_VIDEO_ITEM : UPNP_CLASS_ITEM_MUSIC_TRACK;
        exturl.description = entry.description ? entry.description : ("Entry from " + playlist_title);

        exturl.extra = entry.extra;
        exturl.size = entry.size;
        exturl.writeThrough = entry.writeThrough;

        // This is a special field which ensures that your playlist files
        // will be displayed in the correct order inside a playlist
        // container. It is similar to the id3 track number that is used
        // to sort the media in album containers.
        exturl.playlistOrder = (entry.order ? entry.order : playlistOrder);

        // Your item will be added to the container named by the playlist
        // that you are currently parsing.
        addCdsObject(exturl, playlistChain);
    } else {
        if (entry.location.substr(0,1) !== '/') {
            entry.location = playlistLocation + entry.location;
        }

        var cds = getCdsObject(entry.location);
        if (!cds) {
            print2("Warning", "Playlist '" + playlist_title + "' Skipping unknown entry: " + entry.location);
            return false;
        }

        var item = copyObject(cds);

        item.playlistOrder = (entry.order ? entry.order : playlistOrder);
        item.title = entry.writeThrough <= 0 ? (item.metaData[M_TITLE] ? item.metaData[M_TITLE][0] : cds.title) : entry.title;
        item.metaData[M_CONTENT_CLASS] = [ UPNP_CLASS_PLAYLIST_ITEM ];
        item.description = entry.description ? entry.description : null;

        item.extra = entry.extra;
        item.writeThrough = entry.writeThrough;
        print2("Debug", "Playlist '" + item.title + "' Adding entry: " + item.playlistOrder + " " + item.location);

        addCdsObject(item, playlistChain);
    }
    return true;
}

Main Parsing

The actual parsing is done in the main part of the script. First, the type of the playlist is determined (based on the playlist mimetype), then the correct parser is chosen. The parsing itself is a loop, where each call to readln() returns exactly one line of text from the playlist. There is no possibility to go back, each readln() invocation will retrieve the next line until end of file is reached.

To keep things easy we will only list the m3u parsing here. Again, if you are not familiar with regular expressions, now is probably the time to take a closer look.

function readM3uPlaylist(playlist_title, playlistLocation, playlistChain, playlistDirChain) {
    var entry = {
        title: null,
        location: null,
        order: 0,
        size: -1,
        writeThrough: -1,
        mimetype: null,
        description: null,
        protocol: null,
        extra: [],
    };
    var line = readln();
    var playlistOrder = 1;

    // Remove a BOM if there is one
    if (line && line.charCodeAt(0) === 0xFEFF) {
        line = line.substr(1);
    }

    // Here is the do - while loop which will read the playlist line by line.
    do {
        var matches = line ? line.match(/^#EXTINF:(-?\d+),\s*(\S.*)$/i) : undefined;

        if (matches && matches.length > 1) {
            // duration = matches[1]; // currently unused
            entry.title = matches[2];
            matches = line.match(/^#EXTINF:(-?\d+),\s*(\S.*),\s*(\S.*)$/i);
            if (matches && matches.length > 1) {
                entry.title = matches[2];
                entry.mimetype = matches[3];
            }
        } else if (line && !line.match(/^(#|\s*$)/)) {
            entry.location = line;
            // Call the helper function to add the item once you gathered the data:
            var state = addPlaylistItem(playlist_title, playlistLocation, entry, playlistChain, playlistOrder);

            // Also add to "Directories"
            if (playlistDirChain && state)
                state = addPlaylistItem(playlist_title, playlistLocation, entry, playlistDirChain, playlistOrder);

            entry.title = null;
            entry.mimetype = null;
            if (state)
                playlistOrder++;
        }

        line = readln();
    } while (line);
}

Happy scripting!

Example: How to import and play CD-Images (CUE-File)

Do you want to archive your CDs without loss? With ExactAudioCopy (EAC) you can easily create an image in FLAC format and a CUE file. The CUE file is a text file containing information about the individual tracks. Gerbera allows you to use scripts to read in the CUE file and play back the individual tracks.

Prerequisits

  1. You have a image copy of your CD in the FLAC-format and a CUE-File. The FLAC-File may include the cover art.

  2. Gerbera with the External-Link-Patch.

  3. Linux (here Ubuntu 18.04 is used).

  4. A flac player on your system.

sudo apt install flac

Create a transcoder-script

#!/bin/bash
# flac2wav.sh transcoding script to play a range from a flac file.
#
# Usage flac2wav.sh <base64 encoded parameterstring> <outputfile>
#
# The <parameterstring> is a base64-encoded URL:
# <parameterstring decoded> ::= "params?skip=<time>&until=<time>&filename=<filename>
#   <time> ::= mm:ss.ms
#   <filename> : Fulpath to the flac-file
# The <outputfile> is a FIFO-pipe created by gerbera
#
INPUT="$1"
OUTPUT="$2"
# parse parameters
PARAMSTR=`echo ${INPUT} | sed 's/http:\/\/cue2flac\/\(.*\).*/\1/' | base64 --decode`
SKIP=`echo ${PARAMSTR} | sed 's/params?.*skip=\([0-9:.]*\).*/\1/'`
UNTIL=`echo ${PARAMSTR} | sed 's/params?.*until=\([0-9:.\-]*\).*/\1/'`
FILENAME=`echo ${PARAMSTR} | sed 's/params?.*filename=\"\(.*\)\".*/\1/'`
# default params
if [ "${SKIP}" == "" ]
then
  SKIP="0:00.00"
fi

if [ "${UNTIL}" == "" ]
then
  UNTIL="-0:00.00"
fi

# if a filename is given, try transcoding
if [ "${FILENAME}" != "" ]
then
  # transcoding command from flac to raw wav with a range
  exec /usr/bin/flac -f -c -d "${FILENAME}" --skip=${SKIP} --until=${UNTIL} > ${OUTPUT} 2>/dev/null
fi

Modify Your Import Scripts

Add following code to the playlists.js script inside the if-statement.

else if (type == 'cue')
{
  // .cue file parsing tested only with one audio file and multiplse tracks,
  // but this should work also with multiple audio files
  var thisGenre = null;
  var thisDate = null;
  var discArtist = null;
  var discTitle = null;
  var thisArtist = null;
  var thisTitle = null;
  var thisFileName = null;
  var thisTrackIdx = null;
  var thisStart = "0:00.00";
  var thisStop = "-0:00.00";

  var fileType = null;

  var inTrackIdx = 0;
  var indexDone = 0;

  var oldGenre = null;
  var oldDate = null;
  var oldArtist = null;
  var oldTitle = null;
  var oldFileName = null;
  var oldTrackIdx = null;
  var oldStart = "0:00.00";
  var oldStuff = 0;
  var regarray;

  var inLine = readln();
  do
  {
  if ((regarray = inLine.match(/^REM\s*GENRE\s+(\S+)$/))){
    thisGenre = p2i(regarray[1]);
  } else if ((regarray = inLine.match(/^REM\s*DATE\s+(\d+)$/))){
    thisDate = parseInt(regarray[1], 10);
  } else if ((regarray = inLine.match(/^\s*PERFORMER\s+\"(\S.+)\"$/))){
    if (inTrackIdx < 1){
    // no track specified yet -> assign also the  disc artist
    discArtist = p2i(regarray[1]);
    thisArtist = p2i(regarray[1]);
    } else {
    thisArtist = p2i(regarray[1]);
    }
  } else if ((regarray = inLine.match(/^TITLE\s+\"(\S.+)\"$/))){
    if (inTrackIdx < 1){
    // not in track -> disc title
    discTitle = p2i(regarray[1]);
    } else {
    // inside a track -> track title
    thisTitle = p2i(regarray[1]);
    }
  } else if ((regarray = inLine.match(/^\s*FILE\s+\"(\S.+)\"\s+(\S.+)$/))){
    thisFileName = f2i(regarray[1]);
    fileType = regarray[2];
    if (indexDone > 0){
    // multiple files in same cue -> add entry for this one

    createSubItem(thisFileName, thisGenre, thisDate, discArtist, discTitle, thisArtist, thisTitle, thisTrackIdx, thisStart, thisStop);
    indexDone = 0;
    thisStart = "0:00.00";
    thisStop = "-0:00.00";
    inTrackIdx = 0;
    }
  } else if ((regarray = inLine.match(/^\s*TRACK\s+(\d+)\s*AUDIO\s*$/))){
    thisTrackIdx = parseInt(regarray[1], 10);
    if ((thisTrackIdx != inTrackIdx) && (inTrackIdx > 0)){
     // a new track, so the earlier should be ok
     oldGenre = thisGenre;
     oldDate = thisDate;
     oldArtist = thisArtist;
     oldTitle = thisTitle;
     oldFileName = thisFileName;
     oldTrackIdx = inTrackIdx;
     oldStart = thisStart;
     oldStuff = 1;
    }
    inTrackIdx = thisTrackIdx;
    indexDone = 0;

    inTrack = 1;
  } else if ((regarray = inLine.match(/^\s*INDEX\s+(\d+)\s*(\d+):(\d+):(\d+)\s*$/))){
    var tmpIdx = parseInt(regarray[1], 10);
    var tmpMin = parseInt(regarray[2], 10);
    var tmpSec = parseInt(regarray[3], 10);
    var tmpFrames = parseInt(regarray[4], 10);

    // the time stamp is in min:sec:frames format which has to be changes
    var timeSec = tmpSec + tmpFrames/74.0;

    var tmpStamp = null;
    if (timeSec < 10){
    tmpStamp = tmpMin + ':0' + timeSec;
    } else {
    tmpStamp = tmpMin + ':' + timeSec;
    }

    if (indexDone < 1){
     if (oldStuff > 0){
      createSubItem(oldFileName, oldGenre, oldDate, discArtist, discTitle, oldArtist, oldTitle, oldTrackIdx, oldStart, tmpStamp);
      indexDone = 0;
      thisStart = tmpStamp;
      thisStop = "-0:00";

      oldGenre = null;
      oldDate = null;
      oldArtist = null;
      oldTitle = null;
      oldFileName = null;
      oldTrackIdx = null;
      oldStart = null;
      oldStuff = 0;
     } else {
      indexDone = 1;
     }
    }
  }
  inLine = readln();
   }
   while (inLine);
   createSubItem(thisFileName, thisGenre, thisDate, discArtist, discTitle, thisArtist, thisTitle, thisTrackIdx, thisStart, thisStop);
}

And add a function createSubItem to playlists.js:

function createSubItem(thisFileName, thisGenre, thisDate, thisDiscArtist,
     thisDiscTitle, thisArtist, thisTitle, thisTrackIdx, thisStart, thisStop)
{
  if (thisFileName && thisFileName.substr(0,1) != '/'){
   thisFileName = playlistLocation + thisFileName;
  }
  var newItem = new Object();
  newItem.objectType = OBJECT_TYPE_ITEM_EXTERNAL_URL;
  // encode the information to a pseudo-url that is parsed in the transcoding script
  newItem.title = thisFileName;
  var param = "params?skip=";
  if (thisStart){
   param += thisStart;
  } else {
   param +=  "0:00.00";
  }
  param +=  "&until=";
  if (thisStop){
   param +=  thisStop;
  } else {
   param +=  "-0:00.00";
  }
  param +=  "&filename=\"" + thisFileName + "\"";
  newItem.parameter = btoa(param);
  newItem.location = playlistLocation;
  newItem.upnpclass = UPNP_CLASS_ITEM_MUSIC_TRACK;
  newItem.theora = 0;
  newItem.onlineservice = ONLINE_SERVICE_NONE;
  newItem.mimetype = "audio/x-cue+flac";
  newItem.meta = new Array();
  if (thisTitle){
  newItem.meta[M_TITLE] = thisTitle;
  } else {
  newItem.meta[M_TITLE] = thisFileName;
  }
  if (thisArtist){
  newItem.meta[M_ARTIST] = thisArtist;
  } else {
  if (thisDiscArtist){
    newItem.meta[M_ARTIST] = thisDiscArtist;
  }
  }
  if (thisDiscTitle){
  newItem.meta[M_ALBUM] = thisDiscTitle;
  }
  if (thisDate){
  newItem.meta[M_DATE] = thisDate;
  }
  if (thisGenre){
   newItem.meta[M_GENRE] = thisGenre;
  }
  if (thisTrackIdx){
   newItem.meta[M_TRACKNUMBER] = thisTrackIdx;
   newItem.playlistOrder = thisTrackIdx;
  }
  addAudio(newItem);
}

You see that we create for each track a external link object with a mime type “audio/x-cue+flac”. We will need “our” mime type to use our transcoding script.

In my case the JavaScipt-funtion btoa was not included in the runtime environment so we add our own btoa() to common.js.

var Base64 = new function() {
  var keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
  this.encode = function(input) {
  var output = "";
  var chr1, chr2, chr3, enc1, enc2, enc3, enc4;
  var i = 0;
  input = Base64._utf8_encode(input);
  while (i < input.length) {
    chr1 = input.charCodeAt(i++);
    chr2 = input.charCodeAt(i++);
    chr3 = input.charCodeAt(i++);
    enc1 = chr1 >> 2;
    enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
    enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
    enc4 = chr3 & 63;
    if (isNaN(chr2)) {
    enc3 = enc4 = 64;
    } else if (isNaN(chr3)) {
    enc4 = 64;
    }
    output = output + keyStr.charAt(enc1) + keyStr.charAt(enc2) + keyStr.charAt(enc3) + keyStr.charAt(enc4);
  }
  return output;
  }

  this.decode = function(input) {
  var output = "";
  var chr1, chr2, chr3;
  var enc1, enc2, enc3, enc4;
  var i = 0;
  input = input.replace(/[^A-Za-z0-9\+\/\=]/g, "");
  while (i < input.length) {
    enc1 = keyStr.indexOf(input.charAt(i++));
    enc2 = keyStr.indexOf(input.charAt(i++));
    enc3 = keyStr.indexOf(input.charAt(i++));
    enc4 = keyStr.indexOf(input.charAt(i++));
    chr1 = (enc1 << 2) | (enc2 >> 4);
    chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
    chr3 = ((enc3 & 3) << 6) | enc4;
    output = output + String.fromCharCode(chr1);
    if (enc3 != 64) {
    output = output + String.fromCharCode(chr2);
    }
    if (enc4 != 64) {
    output = output + String.fromCharCode(chr3);
    }
  }
  output = Base64._utf8_decode(output);
  return output;
  }

  this._utf8_encode = function(string) {
  string = string.replace(/\r\n/g, "\n");
  var utftext = "";
  for (var n = 0; n < string.length; n++) {
    var c = string.charCodeAt(n);
    if (c < 128) {
    utftext += String.fromCharCode(c);
    } else if ((c > 127) && (c < 2048)) {
    utftext += String.fromCharCode((c >> 6) | 192);
    utftext += String.fromCharCode((c & 63) | 128);
    } else {
    utftext += String.fromCharCode((c >> 12) | 224);
    utftext += String.fromCharCode(((c >> 6) & 63) | 128);
    utftext += String.fromCharCode((c & 63) | 128);
    }
  }
  return utftext;
  }

  this._utf8_decode = function(utftext) {
  var string = "";
  var i = 0;
  var c = 0,
    c1 = 0,
    c2 = 0,
    c3 = 0;
  while (i < utftext.length) {
    c = utftext.charCodeAt(i);
    if (c < 128) {
    string += String.fromCharCode(c);
    i++;
    } else if ((c > 191) && (c < 224)) {
    c2 = utftext.charCodeAt(i + 1);
    string += String.fromCharCode(((c & 31) << 6) | (c2 & 63));
    i += 2;
    } else {
    c2 = utftext.charCodeAt(i + 1);
    c3 = utftext.charCodeAt(i + 2);
    string += String.fromCharCode(((c & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63));
    i += 3;
    }
  }
  return string;
  }
}()

var btoa = Base64.encode;
var atob = Base64.decode;

Inside the function addAudio(obj) insert following code according to your virtual layout:

if (obj.objectType == OBJECT_TYPE_ITEM_EXTERNAL_URL) {
  chain = new Array('CD Images', artist, album);
  // until now obj.location contains the full path of the object
  // the attribute obj.parameter has got the base64 encoded parameter string
  // now we must create the location with a pseudo-url
  obj.location = "http://cue2flac/" + obj.parameter;
  addCdsObject(obj, createContainerChain(chain), UPNP_CLASS_CONTAINER_MUSIC_ALBUM);
}

Edit Your config.xml

In the config.xml you must tell gerbera to treat cue-files as a playlist. Make sure that <playlist-script> is set correctly:

<mappings>
  ...
  <extension-mimetype ignore-unknown="no">
    ...
    <map from="cue" to="audio/x-cue"/>
    ...
  </extension-mimetype>
  ...
  <mimetype-contenttype>
    ...
    <treat mimetype="audio/x-cue" as="playlist"/>
    ...
  </mimetype-contenttype>
  ...
</mappings>

Enable transcoding and set the correct path to the transcoding script.

<transcoding enabled="yes">
  <mimetype-profile-mappings>
    ...
    <transcode mimetype="audio/x-cue+flac" using="flac2wav"/>
    ...
  </mimetype-profile-mappings>
  <profiles>
    ...
    <profile name="/your/path/to/flac2wav" enabled="yes" type="external">
      <mimetype>audio/x-wav</mimetype>
      <accept-url>yes</accept-url>
      <first-resource>yes</first-resource>
      <hide-original-resource>yes</hide-original-resource>
      <accept-ogg-theora>no</accept-ogg-theora>
      <agent command="flac2wav.sh" arguments="%in %out"/>
      <buffer size="1048576" chunk-size="131072" fill-size="65536"/>
    </profile>
    ...
  </profiles>
</transcoding>

Have fun!

Best practices for testing

Writing a custom import script can be tricky. Here are some hints:

  • Gerbera needs to be restarted to pick up changes to the import script.

  • During startup, Gerbera scans all directories defined in the autoscan configuration - this takes time. Limit autoscan to a directory containing only few files.

  • When autoscan is configured, the import script can be triggered by running touch /path/to/file.ogg. The “Add” button in the web interface does not trigger the import script if Gerbera already knows about the file.

  • Do not modify the provided common.js file. Override its functions in a separate file and let Gerbera load it by specifying it in custom-script in config.xml.