Players

Essential Components

There are a few required components that every media player app needs to have in order to make it work with the central Homey media component. While most of the features such as playback, playlists, seeking, repeat and shuffle are taken care of by Homey, your app will still need to provide the media for Homey to provide playback.

/app.js

'use strict';

const Homey = require('homey');

// When the Homey media manager triggers a search event it requests result from each registered media app.
Homey.ManagerMedia.on('search', (query, callback) => {
    // We request the first 10 matches from our media app.
    MyMediaApp.get('/tracks', { q: searchQuery, limit: 10 }, (err, tracks) => {
        if (err) {
            return callback(err);
        }
        const result = [];

        // Populate the result array with ManagerMedia#Track objects.
        tracks.forEach((track) => {
            result.push({
                type: 'track',
                id: track.id,
                title: track.title,
                artist: [
                    {
                        name: track.artist.name,
                        type: 'artist',
                    },
                ],
                album: track.album,
                duration: track.duration,
                artwork: {
                    small: track.artwork_url_small,
                    medium: track.artwork_url_medium,
                    large: track.artwork_url_large,
                },
                genre: track.genre,
                release_date: track.release_date,
                bitrate: track.bitrate,
                codecs: track.codecs,
                bpm: track.bpm,
                confidence: 0.5,
            });
        });
        callback(null, result);
    });
});

// The Homey media manager can request the playback of tracks of it's own accord by notifying the corresponding apps.
Homey.ManagerMedia.on('play', (track, callback) => {
    MyMediaApp.get(`/tracks/${track.track_id}`, (err, track) => {
        if (err) {
            return callback(err);
        }
        // Return an object with the stream_url and optionally update other ManagerMedia#Track properties in this result.
        callback(
            err,
            {
                stream_url: track.stream_url
            }
        );
    });
});

While the responses to both the .on('search') and the .on('play') are very similar there are some key differences. Notice how in our first response we explicitly define the type of the item that we are returning and we also include a confidence value. These two properties are important for the Homey media manager to correctly process the search result. While the type property defines what kind of object is returned the confidence property gives an indication of how certain your app is that the search result is relevant to the query.

The response to the .on('play') omits the previously mentioned properties but includes a stream_url property to tell the Homey media manger where to stream the media from. Another optional property, time_expires, provides an epoch timestamp to indicate when the stream_url expires. If no time_expires is provided Homey assumes that the stream_url expires in 60 seconds. When a stream_url expires and homey is still has need for it, a new request is automatically made in order to refresh the url. Providing meta data as part of the response to an .on('play') is entirely optional but can be used to update changed metadata.

Track Object Model

Services like SoundCloud, Youtube and Spotify all handle their track metadata in different ways. In order to ensure that Homey provides one unified experience we established an Object Model that your media app must adhere to.

In the event that your player app is designed to work alongside a speaker driver app it may be necessary to provide additional information that is not covered by this model. In order to facilitate the needs of developers an options property has been made available. the options property is completely ignored by the Homey media platform itself but can hold additional properties which can then be transported to your speaker device.

attribute type example
id String "S9bCLPwzSC0"
title String "Some Random Song"
artist Array of Objects [ { "name" : "Adele", "type" : "artist" } ]
album String "The Ultimate Collection"
title String "Some Random Song"
duration int (time in ms) 324000
artwork Object { "small": "https://coverart.host.com/mycover/64.jpg", "medium": "https://coverart.host.com/mycover/300.jpg", "large": "https://coverart.host.com/mycover/640.jpg" }
genre String "Jazz"
release_date String "2016-05-16"
bitrate int 160
codecs Array of Strings [Homey.Codec.MP3, "custom:codec:id"]
bpm int 90
options Object { uniqueProperty1: someData, uniqueProperty2: moreData }

Additional features

While the search and play functions are essential for any media app to function properly there are some additional features that can provide a richer experience for users that interact with an app.

OAuth

In order to facilitate external authentications Homey provides an OAuth api that apps can use. For futher information on the OAuth api it is recommend to check out the CloudOAuth2Callback

External Playlists

While the Homey media component can provide playlist functionality for all apps it is possible for apps to provide their own static playlists. These playlists can provide content a user has created in an external service or content generated by the app itself. It is important to note that these playlists cannot be edited by the user using the interface of the Homey media component but can be updated or removed by the app itself.

/app.js

// sends a request to the Homey Media component to refresh static ManagerMedia#Playlist
Homey.ManagerMedia.requestPlaylistsUpdate();

// sends a request to the Homey Media component to refresh a specific static ManagerMedia#Playlist
Homey.ManagerMedia.requestPlaylistsUpdate(playlistId);

It is important to note that in order for static playlists to work as expected media apps should also listen for a pair of events where the Homey media component can request access to the static resources.

/app.js

/*
 * Homey can periodically request static playlist that are available through
 * the streaming API (when applicable)
 */
Homey.ManagerMedia.on('getPlaylists', (callback) => {
    // return errors or an array with all of the ManagerMedia#Playlists the app can offer
    callback(err, playlists);
});

/*
 * Homey might alternatively request a specific playlist so it can be refreshed
 */
Homey.ManagerMedia.on('getPlaylist', (data, callback) => {
    // return errors or a single ManagerMedia#Playlist object that the app can offer
    callback(err, playlist);
});

Playlist Object Model

Just like with Track objects a data model has been established to communicate playlist data with.

attribute type example
id String "S9bCLPwzSC0"
title String "Some Random Playlist"
tracks Array [ { TrackObject 1 }, { TrackObject 2 } ]

Full example

A simple yet full media app could look like this.

/app.js

'use strict';

const Homey = require('homey');

class SoundCloudApp extends Homey.App {
    /**
     * Initialize SoundCloud app with the necessary information:
     * - client ID
     * - client secret
     * - redirect URI
     *
     * Once initialised respond to search, play and playlist request
     * from the Homey Media Manager.
     */
    onInit() {
        /*
         * Initialize the SoundCloud client with a previously obtained accessToken whenever possible.
         */
        const accessToken = Homey.ManagerSettings.get('accessToken');
        if (accessToken) {
            soundCloud.init({
                id: Homey.env.CLIENT_ID,
                secret: Homey.env.CLIENT_SECRET,
                uri: Homey.env.REDIRECT_URI,
                accessToken: accessToken,
            });
            // sends a request to the Homey Media component to refresh static playlists
            Homey.ManagerMedia.requestPlaylistsUpdate();
            startPollingForUpdates();
        } else {
            soundCloud.init({
                id: Homey.env.CLIENT_ID,
                secret: Homey.env.CLIENT_SECRET,
                uri: Homey.env.REDIRECT_URI,
            });
        }

        /*
         * Respond to a search request by returning an array of parsed search results
         */
        Homey.ManagerMedia.on('search', (queryObject, callback) => {
            const query = this.parseSearchQuery(queryObject);
            /*
             * Execute a search using the soundCloud client.
             * Since we are only interested in streamable results we apply filters.
             */
            soundCloud.get('/tracks', query, (err, tracks) => {
                if (err) {
                    this.error('soundcloud err', err);
                    return callback(err);
                }
                if (tracks) {
                    const result = this.parseTracks(tracks);
                    return callback(null, result);
                }
            });
        });

        /*
         * Respond to a play request by returning a parsed track object.
         * The request object contains a trackId and a codec property to indicate what specific
         * resource and in what codec is wanted for playback.
         */
        Homey.ManagerMedia.on('play', (track, callback) => {
            soundCloud.get(`/tracks/${track.trackId}`, (err, trackData) => {
                if (err) {
                    return callback(err);
                }
                const result = this.parseTrack(trackData);
                result.stream_url = `${trackData.stream_url}?client_id=${Homey.env.CLIENT_ID}`;

                // Follow stream_url to redirect url to support speakers that do not follow redirect urls
                request(result.stream_url, { method: 'HEAD', timeout: 2000 }, (err, res) => {
                    if (err) return callback(err);

                    result.stream_url = res.request.uri.href;
                    // return a ManagerMedia#Track object with the `stream_url` property.
                    // Only the `stream_url` property is required, other track properties are optional
                    return callback(err, result);
                });
            });
        });

        /*
         * Homey can periodically request static playlist that are available through
         * the streaming API (when applicable)
         */
        Homey.ManagerMedia.on('getPlaylists', (callback) => {
            if (!soundCloud.isAuthorized) {
                return callback(null, []);
            }
            const results = [];

            soundCloud.get('/me/playlists', { oauth_token: soundCloud.accessToken, streamable: true }, (err, playlists) => {
                if (!playlists) {
                    return callback(null, []);
                }
                playlists.forEach((playlist) => {
                    // Create a ManagerMedia#Track object and push it in the result array
                    results.push({
                        type: 'playlist',
                        id: playlist.id,
                        title: playlist.title,
                        tracks: this.parseTracks(playlist.tracks) || false,
                    });
                });

                return callback(null, results);
            });
        });

        /*
         * Homey might request a specific playlist so it can be refreshed
         */
        Homey.ManagerMedia.on('getPlaylist', (request, callback) => {
                    if (!soundCloud.isAuthorized) {
                    return callback(new Error('not_authenticated'));
                }

                soundCloud.get(`/me/playlists/${request.playlistId}`, {
                    oauth_token: soundCloud.accessToken,
                    streamable: true
                }, (err, playlist) => {
                    if (err) {
                        return callback(err);
                    }

                    // Create a ManagerMedia#Playlist object
                    const result = {
                        type: 'playlist',
                        id: playlist.id,
                        title: playlist.title,
                        tracks: this.parseTracks(playlist.tracks) || false,
                    };

                return callback(null, result);
            });
        });
    }

    /* ====================================================== */

    /**
     * Initiates OAuth for this media app, this is needed when retrieving information specific to a service account.
     * Some user content might only be available when the user is authenticated.
     *
     * @param callback
     */
    startOAuth2(callback) {
        // if the external oauth server requires an Authorization callback URL set it to https://callback.athom.com/oauth2/callback/
        // this is the app-specific authorize url
        Homey.CloudOAuth2Callback(soundCloud.getConnectUrl())
            .on('url', url => {
                // this function is executed when we got the url to redirect the user to callback
                callback(url);
            })
            .on('code', code => {
                // this function is executed when the authorization code is received (or failed to do so)
                if (code instanceof Error) {
                    return this.error(code);
                }

                soundCloud.authorize(code, (err, accessToken) => {
                    if (err) {
                        return this.error(err);
                    }

                    // store accessToken for future use
                    Homey.ManagerSettings.set('accessToken', accessToken);
                    Homey.ManagerSettings.set('authorized', true);
                    Homey.ManagerApi.realtime('authorized', true);

                    // Client is now authorized and able to make API calls
                    soundCloud.get('/me/playlists', { oauth_token: soundCloud.accessToken, streamable: true }, (err, playlists) => {
                        if (err) {
                            return this.error(err);
                        }

                        if (playlists) {
                            // sends a request to the Homey Media component to refresh static playlists
                            Homey.ManagerMedia.requestPlaylistsUpdate();
                            startPollingForUpdates();
                        }
                    });
                });
            }
        );
    }

    /**
     * We deauthorize this media app to use the account specific information
     * it once had access to by resetting our token and notifying Homey Media
     * the new status.
     * @param callback
     */
    deauthorize(callback) {
        soundCloud.isAuthorized = false;
        soundCloud.accessToken = undefined;
        Homey.ManagerSettings.set('accessToken', undefined);
        Homey.ManagerSettings.set('authorized', false);

        Homey.ManagerMedia.requestPlaylistsUpdate();
        stopPollingForUpdates();
        return callback();
    }

    /**
     * Fetches three different size images for the specified image url that most closely
     * resemble the artwork dimensions in the specification. See ManagerMedia#Artwork
     *
     * @param artworkUrl
     * @returns {ManagerMedia#Artwork} artwork
     */
    parseImage(artworkUrl) {
        if (!artworkUrl) {
            return;
        }

        return {
            small: artworkUrl.replace('-large', '-t67x67'),
            medium: artworkUrl.replace('-large', '-t300x300'),
            large: artworkUrl.replace('-large', '-t500x500'),
        };
    }

    /**
     * Further parses the parsedQuery received from Homey. If no parse properties are found
     * the raw query is used instead.
     *
     * @param parsedQuery
     * @returns {queryObject}
     */
    parseSearchQuery(parsedQuery) {
        let searchObject = { q: parsedQuery.searchQuery, streamable: true, limit: 10 };
        if (parsedQuery.genre) {
            searchObject.genres = parsedQuery.genre;
            if (parsedQuery.track) {
                searchObject.q = parsedQuery.track;
            } else {
                searchObject.q = '*';
            }
        }

        return searchObject;
    }

    /**
     * Parses a raw track into Homey readable ManagerMedia#Track format.
     * Note that the format is slightly different for search queries and play requests.
     *
     * - The search format comes with a confidence property ranging between 0 and 1.0
     *   that indicates how strong of a match the parsed Track is to the original search query.
     *   When in doubt simply use 0.5 as a neutral rating.
     * - The play format has a stream_url property that contains the url that Homey
     *   can use to stream the content.
     * - The play format can also optionally use a time_expires property that contains an epoch timestamp
     *   that indicates when the stream_url expires.
     *
     * @param track to parse
     * @returns {ManagerMedia#Track} Track
     */
    parseTrack(track) {
        return {
            type: 'track',
            id: track.id.toString(),
            title: track.title,
            artist: [
                {
                    name: track.user.username,
                    type: 'artist',
                },
            ],
            duration: track.duration,
            artwork: this.parseImage(track.artwork_url),
            genre: track.genre,
            release_date: `${track.release_year}-${track.release_month}-${track.release_day}`,
            codecs: [Homey.Codec.MP3],
            bpm: track.bpm,
        }
    }

    /**
    * Parses a list of tracks using the parseTrack function and returns the result list
    * @param {Array} tracks - List of soundcloud track objects
    * @returns {ManagerMedia#Track[]} Tracks
    parseTracks(tracks) {
        const result = [];
        if (!tracks) {
            return result;
        }

        tracks.forEach((track) => {
            const parsedTrack = this.parseTrack(track);
            parsedTrack.confidence = 0.5;
            result.push(parsedTrack);
        });

        return result;
    }

    /**
     * Polls SoundCloud for an update every few minutes.
     * If your streaming api offers a some kind of notification service it is
     * highly advised to listen for updates instead.
     */
    startPollingForUpdates() {
        pollingInterval = setInterval(() => {
            // Client is now authorized and able to make API calls
            soundCloud.get('/me/playlists', { oauth_token: soundCloud.accessToken, streamable: true }, (err, playlists) => {
                if (err) {
                    return this.error(err);
                }

                if (playlists) {
                    // sends a request to the Homey Media component to refresh static playlists
                    Homey.ManagerMedia.requestPlaylistsUpdate();
                }
                this.log('soundcloud polled for updates');
            });
        }, 480000);
    }

    /**
     * Stops asking SoundCloud for updates
     */
    stopPollingForUpdates() {
        clearInterval(pollingInterval);
        pollingInterval = false;
    }

}

module.exports = SoundCloudApp;

/api.js

'use strict';

const Homey = require('homey');

module.exports = [

    {
        method: 'GET',
        path: '/oauth2',
        fn: function (callback, args) {
            Homey.app.startOAuth2(callback);
        }
    },

    {
        method: 'POST',
        path: '/deauthorize',
        fn: function (callback, args) {
            Homey.app.deauthorize(callback);
        }
    },

]

/app.json

{
    {
    "id": "com.soundcloud",
    "version": "1.0.0",
    "compatibility": ">=1.2",
    "category": "music",
    "name": {
        "en": "SoundCloud"
    },
    "description": {
        "en": "Play your favorite music from SoundCloud on Homey"
    },
    "author": {
        "name": "Athom B.V.",
        "website": "https://www.athom.com"
    },
    "images": {
        "large": "./assets/images/large.jpg",
        "small": "./assets/images/small.jpg"
    },
    "media": [ "play", "search", "getPlaylists", "getPlaylist" ],
    "dependencies": {
        "node-soundcloud": "*"
    }
}