Skip to content

Commit

Permalink
add the ability to pre-process audio playlists
Browse files Browse the repository at this point in the history
* this is actually a really good feature
* it's also designed in a way that's very easy to extend,
  which would enable pre-processing more URL patterns
  - for each URL pattern interceptor:
    * asked whether it should apply to a requested URL
    * if so, it implements a line parser that extracts URLs from text
* currently, two URL pattern interceptors are implemented
  - .m3u audio playlists
  - .html files
    * this includes URLs that imply a directory index
      will be returned from the web server in .html format
    * current implementation only parses audio files from <a> tags
      - includes logic to dedupe duplicate audio files
        * when the same audio file is available in multiple formats,
          applies a ranking of format preference and only returns
          one URL for the audio file in the most favorable format

notes:
======

* this feature had previously been implemented in the javascript SPA
  - depended upon a CORS proxy to bypass browser security rules
    to be able to download the playlist URL (ex: m3u, html)
* now this feature is available to all clients automagically,
  and there are no cross-origin concerns
* I left the javascript implementation in the SPA for reference sake,
  but all relevent code has been commented out
  • Loading branch information
warren-bank committed Mar 13, 2020
1 parent 999bf09 commit f021067
Show file tree
Hide file tree
Showing 10 changed files with 570 additions and 24 deletions.
41 changes: 38 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ Android app to run on a set-top box and play video URLs "cast" to it with a stat
- this is a great starting point
* it supports: play, pause, seek, stop
- I'd like to extend this API (for a custom sender)
* to add support for: video queue, next, previous, mute, set volume
* to add support for:
- video queue, next, previous, mute, set volume
- audio playlists (_m3u_, _html_ directory index)

#### Design:

Expand Down Expand Up @@ -62,7 +64,7 @@ __AirPlay APIs:__
image_page='https://commons.wikimedia.org/wiki/File:Android_robot.svg'
image_url='https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Android_robot.svg/654px-Android_robot.svg.png'

# URLs for test videos:
# URLs for test video:
videos_page='https://players.akamai.com/hls/'
video_url_1='https://multiplatform-f.akamaihd.net/i/multi/will/bunny/big_buck_bunny_,640x360_400,640x360_700,640x360_1000,950x540_1500,.f4v.csmil/master.m3u8'
video_url_2='https://multiplatform-f.akamaihd.net/i/multi/april11/hdworld/hdworld_,512x288_450_b,640x360_700_b,768x432_1000_b,1024x576_1400_m,.mp4.csmil/master.m3u8'
Expand All @@ -73,6 +75,14 @@ __AirPlay APIs:__
caption_url_1='https://d12zt1n3pd4xhr.cloudfront.net/fp/subtitles-demo.vtt'
caption_url_2='https://d12zt1n3pd4xhr.cloudfront.net/fp/subtitles-demo.vtt'
caption_url_3='https://d12zt1n3pd4xhr.cloudfront.net/fp/subtitles-demo.vtt'

# URLs for test audio:
audio_flac_nfo='https://archive.org/details/tntvillage_457399'
audio_flac_url='https://archive.org/download/tntvillage_457399/Black%20Sabbath%201970-2013/Studio%20Albums/1970%20Black%20Sabbath/1970%20Black%20Sabbath%20%5B1986%20France%20NELCD%206002%20Castle%5D/Black%20Sabbath%20-%20Black%20Sabbath%20%281986%2C%20Castle%20Communications%2C%20NELCD%206002%29.flac'
audio_m3u_page='https://archive.org/details/Mozart_Vesperae_Solennes_de_Confessore'
audio_mp3s_m3u='https://archive.org/download/Mozart_Vesperae_Solennes_de_Confessore/Mozart%20-%20Vesper%C3%A6%20Solennes%20de%20Confessore%20%28Cooke%29.m3u'
audio_htm_page='https://archive.org/details/tntvillage_455310'
audio_mp3s_htm='https://archive.org/download/tntvillage_455310/S%26G/Live/1967%20-%20Live%20From%20New%20York%20City%20%40320/'
```

* display image from local file system:
Expand Down Expand Up @@ -125,7 +135,7 @@ __extended APIs:__

* add video #2 to end of queue (add text captions, set 'Referer' request header, seek to 50%):
```bash
# note: position < 1 is a percent of the total video length
# note: position < 1 is a percent of the total track length
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${video_url_2}\nCaption-Location: ${caption_url_2}\nReferer: ${videos_page}\nStart-Position: 0.5" \
Expand Down Expand Up @@ -189,6 +199,31 @@ __extended APIs:__
curl --silent -X POST -d "" \
"http://${airplay_ip}/set-captions-offset?value=0"
```
* play audio .flac file (set 'Referer' request header, seek to 50%):
```bash
# note: position < 1 is a percent of the total track length
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${audio_flac_url}\nReferer: ${audio_flac_nfo}\nStart-Position: 0.5" \
"http://${airplay_ip}/play"
```
* play audio .m3u playlist (6 songs, set 'Referer' request header for all songs, seek to 30 seconds in first song):
```bash
# note: position >= 1 is a fixed offset (in seconds)
# note: after the first song, each additional song is added following a 1 second delay; adding 6 songs will take 5 seconds to complete.
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${audio_mp3s_m3u}\nReferer: ${audio_m3u_page}\nStart-Position: 30" \
"http://${airplay_ip}/play"
```
* add audio .html directory index playlist to end of queue (20 songs, set 'Referer' request header for all songs, seek to beginning of first song):
```bash
# note: after the first song, each additional song is added following a 1 second delay; adding 20 songs will take 19 seconds to complete.
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${audio_mp3s_htm}\nReferer: ${audio_htm_page}\nStart-Position: 0" \
"http://${airplay_ip}/queue"
```

#### Usage (high level):

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Locale;

Expand All @@ -17,7 +18,9 @@
import android.net.wifi.WifiManager;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.util.Log;
import android.view.Gravity;
Expand All @@ -28,6 +31,8 @@
import com.github.warren_bank.exoplayer_airplay_receiver.MainApp;
import com.github.warren_bank.exoplayer_airplay_receiver.constant.Constant;
import com.github.warren_bank.exoplayer_airplay_receiver.httpcore.RequestListenerThread;
import com.github.warren_bank.exoplayer_airplay_receiver.service.playlist_extractors.HtmlPlaylistExtractor;
import com.github.warren_bank.exoplayer_airplay_receiver.service.playlist_extractors.M3uPlaylistExtractor;
import com.github.warren_bank.exoplayer_airplay_receiver.ui.ImageActivity;
import com.github.warren_bank.exoplayer_airplay_receiver.ui.VideoPlayerActivity;
import com.github.warren_bank.exoplayer_airplay_receiver.utils.NetworkUtils;
Expand Down Expand Up @@ -65,7 +70,11 @@ public void onCreate() {
lock.setReferenceCounted(true);
lock.acquire();

handler = new ServiceHandler(NetworkingService.this);
// run ServiceHandler in a separate Thread to avoid NetworkOnMainThreadException
HandlerThread handlerThread = new HandlerThread("ServiceHandlerThread");
handlerThread.start();

handler = new ServiceHandler(handlerThread.getLooper(), NetworkingService.this);
MainApp.registerHandler(NetworkingService.class.getName(), handler);

Toast toast = android.widget.Toast.makeText(getApplicationContext(), "Registering Airplay service...", android.widget.Toast.LENGTH_SHORT);
Expand Down Expand Up @@ -333,10 +342,43 @@ private void processIntent(Intent intent) {
// message handler

private static class ServiceHandler extends Handler {

private class DelayedVideoPlayerIntentRunnable implements Runnable {
private NetworkingService service;
private String uri;
private String referer;

public DelayedVideoPlayerIntentRunnable(NetworkingService service, String uri, String referer) {
this.service = service;
this.uri = uri;
this.referer = referer;
}

@Override
public void run() {
sendVideoPlayerIntent(
service,
/* mode= */ "queue",
/* uri= */ uri,
/* caption= */ "",
/* referer= */ referer,
/* startPosition= */ 0d
);
}
}

private WeakReference<NetworkingService> weakReference;

public ServiceHandler(NetworkingService service) {
private M3uPlaylistExtractor m3uExtractor;
private HtmlPlaylistExtractor htmlExtractor;

public ServiceHandler(Looper looper, NetworkingService service) {
super(looper);

weakReference = new WeakReference<NetworkingService>(service);

m3uExtractor = new M3uPlaylistExtractor();
htmlExtractor = new HtmlPlaylistExtractor();
}

@Override
Expand All @@ -361,11 +403,10 @@ public void handleMessage(Message msg) {
break;
}
case Constant.Msg.Msg_Photo : {
byte[] pic = (byte[]) msg.obj;
Intent intent = new Intent(service, ImageActivity.class);
intent.putExtra("picture", pic);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
service.startActivity(intent);
sendImageViewerIntent(
service,
/* pic= */ (byte[]) msg.obj
);
break;
}
case Constant.Msg.Msg_Video_Play :
Expand All @@ -376,18 +417,71 @@ public void handleMessage(Message msg) {
String referUrl = map.get(Constant.RefererURL);
String startPos = map.get(Constant.Start_Pos);

Intent intent = new Intent(service, VideoPlayerActivity.class);
intent.putExtra("mode", ((msg.what == Constant.Msg.Msg_Video_Play) ? "play" : "queue"));
intent.putExtra("uri", playUrl);
intent.putExtra("caption", textUrl);
intent.putExtra("referer", referUrl);
intent.putExtra("startPosition", Double.valueOf(startPos));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
service.startActivity(intent);
ArrayList<String> matches = null;

if (matches == null)
matches = m3uExtractor.expandPlaylist(playUrl); //8-bit ascii

if (matches == null)
matches = htmlExtractor.expandPlaylist(playUrl, (String) null); //utf8

if (matches != null) {
for (int counter = 0; counter < matches.size(); counter++) {
if (counter == 0) {
sendVideoPlayerIntent(
service,
/* mode= */ ((msg.what == Constant.Msg.Msg_Video_Play) ? "play" : "queue"),
/* uri= */ matches.get(counter),
/* caption= */ textUrl,
/* referer= */ referUrl,
/* startPosition= */ Double.valueOf(startPos)
);
}
else {
ServiceHandler.this.postDelayed(
new DelayedVideoPlayerIntentRunnable(
service,
/* uri= */ matches.get(counter),
/* referer= */ referUrl
),
(1000l * counter)
);
}
}
break;
}

sendVideoPlayerIntent(
service,
/* mode= */ ((msg.what == Constant.Msg.Msg_Video_Play) ? "play" : "queue"),
/* uri= */ playUrl,
/* caption= */ textUrl,
/* referer= */ referUrl,
/* startPosition= */ Double.valueOf(startPos)
);
break;
}

}
}

private void sendVideoPlayerIntent(NetworkingService service, String mode, String uri, String caption, String referer, double startPosition) {
Intent intent = new Intent(service, VideoPlayerActivity.class);
intent.putExtra("mode", mode);
intent.putExtra("uri", uri);
intent.putExtra("caption", caption);
intent.putExtra("referer", referer);
intent.putExtra("startPosition", startPosition);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
service.startActivity(intent);
}

private void sendImageViewerIntent(NetworkingService service, byte[] pic) {
Intent intent = new Intent(service, ImageActivity.class);
intent.putExtra("picture", pic);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
service.startActivity(intent);
}

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.github.warren_bank.exoplayer_airplay_receiver.service.playlist_extractors;

import com.github.warren_bank.exoplayer_airplay_receiver.utils.StringUtils;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URL;
import java.nio.charset.Charset;
import java.util.ArrayList;

public abstract class BasePlaylistExtractor {

protected abstract boolean isParserForUrl(String strUrl);

protected abstract void parseLine(String line, URL context, ArrayList<String> matches);

protected void preParse(URL context) {}

protected void postParse(URL context, ArrayList<String> matches) {}

public ArrayList<String> expandPlaylist(String strUrl) {
// https://developer.android.com/reference/java/nio/charset/Charset#standard-charsets
// https://en.wikipedia.org/wiki/Extended_ASCII#ISO_8859_and_proprietary_adaptations
// https://en.wikipedia.org/wiki/ISO/IEC_8859-1
return expandPlaylist(strUrl, "ISO-8859-1");
}

public ArrayList<String> expandPlaylist(String strUrl, String charsetName) {
Charset cs = null;

if ((charsetName == null) || charsetName.isEmpty()) {
cs = Charset.defaultCharset(); // UTF-8
}
else {
try {
cs = Charset.forName(charsetName);
}
catch (Exception e) {
cs = Charset.defaultCharset(); // UTF-8
}
}

return expandPlaylist(strUrl, cs);
}

protected ArrayList<String> expandPlaylist(String strUrl, Charset cs) {
if (!isParserForUrl(strUrl))
return null;

ArrayList<String> matches = new ArrayList<String>();
BufferedReader in = null;

try {
URL url;
String line;

// ascii encoded
url = new URL(strUrl);

// Read all the text returned by the server
in = new BufferedReader(new InputStreamReader(url.openStream(), cs));

// remove ascii encoding
url = new URL(StringUtils.decodeURL(strUrl));

preParse(url);
while ((line = in.readLine()) != null) {
// `line` is one line of text; readLine() strips the newline character(s)
parseLine(line, url, matches);
}
postParse(url, matches);
}
catch (Exception e) {
}
finally {
if (in != null) {
try {
in.close();
}
catch(Exception e) {}
}

// normalize that non-null return value must include matches
if (matches.isEmpty())
matches = null;
}

return matches;
}

}
Loading

0 comments on commit f021067

Please sign in to comment.