Add FFmpeg downloaders
This commit is contained in:
parent
5c0e1ae044
commit
d2f490f8f6
|
@ -31,7 +31,7 @@ import ctbrec.Recording.State;
|
|||
import ctbrec.StringUtil;
|
||||
import ctbrec.recorder.ProgressListener;
|
||||
import ctbrec.recorder.Recorder;
|
||||
import ctbrec.recorder.download.hls.MergedHlsDownload;
|
||||
import ctbrec.recorder.download.hls.MergedFfmpegHlsDownload;
|
||||
import ctbrec.sites.Site;
|
||||
import ctbrec.ui.AutosizeAlert;
|
||||
import ctbrec.ui.CamrecApplication;
|
||||
|
@ -505,7 +505,7 @@ public class RecordingsTab extends Tab implements TabSelectionListener {
|
|||
String hlsBase = config.getServerUrl() + "/hls";
|
||||
if (recording.isSegmented()) {
|
||||
URL url = new URL(hlsBase + recording.getPath() + "/playlist.m3u8");
|
||||
MergedHlsDownload download = new MergedHlsDownload(CamrecApplication.httpClient);
|
||||
MergedFfmpegHlsDownload download = new MergedFfmpegHlsDownload(CamrecApplication.httpClient);
|
||||
LOG.info("Downloading {}", url);
|
||||
download.downloadFinishedRecording(url.toString(), target, createDownloadListener(recording));
|
||||
} else {
|
||||
|
|
|
@ -11,8 +11,8 @@ import com.squareup.moshi.JsonReader;
|
|||
import com.squareup.moshi.JsonWriter;
|
||||
|
||||
import ctbrec.recorder.download.Download;
|
||||
import ctbrec.recorder.download.hls.FFmpegDownload;
|
||||
import ctbrec.recorder.download.hls.HlsDownload;
|
||||
import ctbrec.recorder.download.hls.MergedHlsDownload;
|
||||
import ctbrec.sites.Site;
|
||||
|
||||
public abstract class AbstractModel implements Model {
|
||||
|
@ -230,10 +230,12 @@ public abstract class AbstractModel implements Model {
|
|||
|
||||
@Override
|
||||
public Download createDownload() {
|
||||
if(Config.isServerMode()) {
|
||||
if (Config.isServerMode()) {
|
||||
return new HlsDownload(getSite().getHttpClient());
|
||||
} else {
|
||||
return new MergedHlsDownload(getSite().getHttpClient());
|
||||
// return new MergedHlsDownload(getSite().getHttpClient());
|
||||
//return new MergedFfmpegHlsDownload(getSite().getHttpClient());
|
||||
return new FFmpegDownload(getSite().getHttpClient());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,6 +50,7 @@ public class Settings {
|
|||
public List<EventHandlerConfiguration> eventHandlers = new ArrayList<>();
|
||||
public String fc2livePassword = "";
|
||||
public String fc2liveUsername = "";
|
||||
public String ffmpegMergedDownloadArgs = "-i - -c:v copy -c:a copy -movflags faststart -y -f mp4";
|
||||
public String flirt4freePassword;
|
||||
public String flirt4freeUsername;
|
||||
public boolean generatePlaylist = true;
|
||||
|
|
|
@ -276,7 +276,7 @@ public class NextGenLocalRecorder implements Recorder {
|
|||
model.setLastRecorded(rec.getStartDate());
|
||||
recordingManager.saveRecording(rec);
|
||||
download.start();
|
||||
} catch (IOException e) {
|
||||
} catch (Exception e) {
|
||||
LOG.error("Download for {} failed. Download state: {}", model.getName(), rec.getStatus(), e);
|
||||
}
|
||||
boolean deleted = deleteIfEmpty(rec);
|
||||
|
|
|
@ -85,7 +85,7 @@ public abstract class AbstractHlsDownload extends AbstractDownload {
|
|||
.header(CONNECTION, KEEP_ALIVE)
|
||||
.build();
|
||||
Exception lastException = null;
|
||||
for (int tries = 1; tries <= 10; tries++) {
|
||||
for (int tries = 1; tries <= 10 && running; tries++) {
|
||||
try (Response response = client.execute(request)) {
|
||||
if (response.isSuccessful()) {
|
||||
String body = response.body().string();
|
||||
|
@ -132,6 +132,9 @@ public abstract class AbstractHlsDownload extends AbstractDownload {
|
|||
} catch (Exception e) {
|
||||
LOG.debug("Couldn't download HLS playlist (try {}) {} - [{}]", tries, e.getMessage(), segmentsURL);
|
||||
lastException = e;
|
||||
if (!getModel().isOnline(true)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
waitSomeTime(100 * tries);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,136 @@
|
|||
package ctbrec.recorder.download.hls;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.xml.bind.JAXBException;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.iheartradio.m3u8.ParseException;
|
||||
import com.iheartradio.m3u8.PlaylistException;
|
||||
|
||||
import ctbrec.Config;
|
||||
import ctbrec.Model;
|
||||
import ctbrec.Recording;
|
||||
import ctbrec.io.HttpClient;
|
||||
import ctbrec.io.StreamRedirectThread;
|
||||
import ctbrec.recorder.download.ProcessExitedUncleanException;
|
||||
|
||||
public class FFmpegDownload extends AbstractHlsDownload {
|
||||
private static final transient Logger LOG = LoggerFactory.getLogger(FFmpegDownload.class);
|
||||
|
||||
private transient Config config;
|
||||
private transient Process ffmpeg;
|
||||
private File targetFile;
|
||||
|
||||
public FFmpegDownload(HttpClient client) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Config config, Model model, Instant startTime) {
|
||||
this.config = config;
|
||||
this.model = model;
|
||||
this.startTime = startTime;
|
||||
targetFile = config.getFileForRecording(model, "mp4", startTime);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() throws IOException {
|
||||
try {
|
||||
Files.createDirectories(targetFile.getParentFile().toPath());
|
||||
String chunkPlaylist = getSegmentPlaylistUrl(model);
|
||||
|
||||
// @formatter:off
|
||||
ffmpeg = Runtime.getRuntime().exec(new String[] {
|
||||
"/usr/bin/ffmpeg",
|
||||
"-y", // overwrite output files without asking
|
||||
"-headers", "User-Agent: " + config.getSettings().httpUserAgent,
|
||||
"-i", chunkPlaylist,
|
||||
"-c", "copy",
|
||||
"-f", "mp4",
|
||||
targetFile.getCanonicalPath()
|
||||
});
|
||||
// @formatter:on
|
||||
int exitCode = 1;
|
||||
File ffmpegLog = File.createTempFile(targetFile.getName(), ".log");
|
||||
try (FileOutputStream mergeLogStream = new FileOutputStream(ffmpegLog)) {
|
||||
Thread stdout = new Thread(new StreamRedirectThread(ffmpeg.getInputStream(), mergeLogStream));
|
||||
Thread stderr = new Thread(new StreamRedirectThread(ffmpeg.getErrorStream(), mergeLogStream));
|
||||
stdout.start();
|
||||
stderr.start();
|
||||
exitCode = ffmpeg.waitFor();
|
||||
stdout.join();
|
||||
stderr.join();
|
||||
mergeLogStream.flush();
|
||||
}
|
||||
if (exitCode == 0) {
|
||||
if (ffmpegLog.exists()) {
|
||||
Files.delete(ffmpegLog.toPath());
|
||||
}
|
||||
} else {
|
||||
LOG.info("FFmpeg exit code was {}. Logfile: {}", exitCode, ffmpegLog.getAbsolutePath());
|
||||
throw new ProcessExitedUncleanException("FFmpeg exit code was " + exitCode);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
LOG.error("Thread interrupted while waiting for FFmpeg to terminate");
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (ExecutionException | ParseException | PlaylistException | JAXBException e) {
|
||||
LOG.error("Couldn't start FFmpeg process for stream download", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
if (ffmpeg != null && ffmpeg.isAlive()) {
|
||||
ffmpeg.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Model getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration getLength() {
|
||||
return Duration.between(startTime, Instant.now());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postprocess(Recording recording) {
|
||||
Thread.currentThread().setName("PP " + model.getName());
|
||||
try {
|
||||
runPostProcessingScript(recording);
|
||||
} catch (Exception e) {
|
||||
throw new PostProcessingException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public File getTarget() {
|
||||
return targetFile;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPath(Model model) {
|
||||
String absolutePath = getTarget().getAbsolutePath();
|
||||
String recordingsDir = Config.getInstance().getSettings().recordingsDir;
|
||||
String relativePath = absolutePath.replaceFirst(Pattern.quote(recordingsDir), "");
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
@Override
|
||||
void internalStop() {
|
||||
stop();
|
||||
}
|
||||
|
||||
}
|
|
@ -177,12 +177,6 @@ public class HlsDownload extends AbstractHlsDownload {
|
|||
} catch (Exception e) {
|
||||
throw new IOException("Couldn't download segment", e);
|
||||
} finally {
|
||||
try {
|
||||
Thread.sleep(10_000);
|
||||
} catch (InterruptedException e1) {
|
||||
// TODO Auto-generated catch block
|
||||
e1.printStackTrace();
|
||||
}
|
||||
downloadThreadPool.shutdown();
|
||||
try {
|
||||
LOG.debug("Waiting for last segments for {}", model);
|
||||
|
@ -294,7 +288,7 @@ public class HlsDownload extends AbstractHlsDownload {
|
|||
|
||||
@Override
|
||||
public Boolean call() throws Exception {
|
||||
LOG.trace("Downloading segment to {}", file);
|
||||
LOG.trace("Downloading segment {} to {}", url, file);
|
||||
for (int tries = 1; tries <= 3; tries++) {
|
||||
Request request = new Request.Builder()
|
||||
.url(url)
|
||||
|
|
|
@ -0,0 +1,488 @@
|
|||
package ctbrec.recorder.download.hls;
|
||||
|
||||
import java.io.EOFException;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedList;
|
||||
import java.util.Optional;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.jcodec.containers.mp4.MP4Util;
|
||||
import org.jcodec.containers.mp4.boxes.MovieBox;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.iheartradio.m3u8.ParseException;
|
||||
import com.iheartradio.m3u8.PlaylistException;
|
||||
|
||||
import ctbrec.Config;
|
||||
import ctbrec.Hmac;
|
||||
import ctbrec.Model;
|
||||
import ctbrec.OS;
|
||||
import ctbrec.Recording;
|
||||
import ctbrec.io.HttpClient;
|
||||
import ctbrec.io.HttpException;
|
||||
import ctbrec.io.StreamRedirectThread;
|
||||
import ctbrec.recorder.ProgressListener;
|
||||
import ctbrec.recorder.download.ProcessExitedUncleanException;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
public class MergedFfmpegHlsDownload extends AbstractHlsDownload {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(MergedFfmpegHlsDownload.class);
|
||||
private static final boolean IGNORE_CACHE = true;
|
||||
private ZonedDateTime splitRecStartTime;
|
||||
private File targetFile;
|
||||
private boolean downloadFinished = false;
|
||||
private transient Config config;
|
||||
private transient Process ffmpeg;
|
||||
private transient OutputStream ffmpegStdIn;
|
||||
private transient Thread ffmpegThread;
|
||||
|
||||
public MergedFfmpegHlsDownload(HttpClient client) {
|
||||
super(client);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Config config, Model model, Instant startTime) {
|
||||
super.startTime = startTime;
|
||||
this.config = config;
|
||||
this.model = model;
|
||||
targetFile = Config.getInstance().getFileForRecording(model, "mp4", startTime);
|
||||
}
|
||||
|
||||
@Override
|
||||
public File getTarget() {
|
||||
return targetFile;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() throws IOException {
|
||||
try {
|
||||
if (!model.isOnline(IGNORE_CACHE)) {
|
||||
throw new IOException(model.getName() + "'s room is not public");
|
||||
}
|
||||
|
||||
running = true;
|
||||
super.startTime = Instant.now();
|
||||
splitRecStartTime = ZonedDateTime.now();
|
||||
|
||||
Files.createDirectories(targetFile.getParentFile().toPath());
|
||||
startFfmpegProcess(targetFile);
|
||||
|
||||
String segments = getSegmentPlaylistUrl(model);
|
||||
downloadSegments(segments, true);
|
||||
ffmpegThread.join();
|
||||
} catch (ParseException e) {
|
||||
throw new IOException("Couldn't parse stream information", e);
|
||||
} catch (PlaylistException e) {
|
||||
throw new IOException("Couldn't parse HLS playlist", e);
|
||||
} catch (EOFException e) {
|
||||
// end of playlist reached
|
||||
LOG.debug("Reached end of playlist for model {}", model);
|
||||
} catch (Exception e) {
|
||||
throw new IOException("Couldn't download segment", e);
|
||||
} finally {
|
||||
internalStop();
|
||||
downloadThreadPool.shutdown();
|
||||
try {
|
||||
LOG.debug("Waiting for last segments for {}", model);
|
||||
downloadThreadPool.awaitTermination(60, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
downloadFinished = true;
|
||||
LOG.debug("Download for {} terminated", model);
|
||||
}
|
||||
}
|
||||
|
||||
private void startFfmpegProcess(File target) {
|
||||
ffmpegThread = new Thread(() -> {
|
||||
try {
|
||||
String[] args = Config.getInstance().getSettings().ffmpegMergedDownloadArgs.split(" ");
|
||||
String[] argsPlusFile = new String[args.length + 1];
|
||||
System.arraycopy(args, 0, argsPlusFile, 0, args.length);
|
||||
argsPlusFile[argsPlusFile.length-1] = target.getAbsolutePath();
|
||||
String[] cmdline = OS.getFFmpegCommand(argsPlusFile);
|
||||
LOG.debug("Command line: {}", Arrays.toString(cmdline));
|
||||
ffmpeg = Runtime.getRuntime().exec(cmdline, new String[0], target.getParentFile());
|
||||
ffmpegStdIn = ffmpeg.getOutputStream();
|
||||
int exitCode = 1;
|
||||
File ffmpegLog = File.createTempFile(target.getName(), ".log");
|
||||
try (FileOutputStream mergeLogStream = new FileOutputStream(ffmpegLog)) {
|
||||
Thread stdout = new Thread(new StreamRedirectThread(ffmpeg.getInputStream(), mergeLogStream));
|
||||
Thread stderr = new Thread(new StreamRedirectThread(ffmpeg.getErrorStream(), mergeLogStream));
|
||||
stdout.start();
|
||||
stderr.start();
|
||||
exitCode = ffmpeg.waitFor();
|
||||
stdout.join();
|
||||
stderr.join();
|
||||
mergeLogStream.flush();
|
||||
}
|
||||
if (exitCode == 0) {
|
||||
if (ffmpegLog.exists()) {
|
||||
Files.delete(ffmpegLog.toPath());
|
||||
}
|
||||
} else {
|
||||
LOG.info("FFmpeg exit code was {}. Logfile: {}", exitCode, ffmpegLog.getAbsolutePath());
|
||||
throw new ProcessExitedUncleanException("FFmpeg exit code was " + exitCode);
|
||||
}
|
||||
} catch (IOException | ProcessExitedUncleanException e) {
|
||||
LOG.error("Error in FFMpeg thread", e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
LOG.info("Interrupted while waiting for ffmpeg", e);
|
||||
// maybe kill / terminate ffmpeg here?!?
|
||||
}
|
||||
});
|
||||
ffmpegThread.setName("FFmpeg");
|
||||
ffmpegThread.start();
|
||||
}
|
||||
|
||||
private void downloadSegments(String segmentPlaylistUri, boolean livestreamDownload) throws IOException, ParseException, PlaylistException {
|
||||
int lastSegment = 0;
|
||||
int nextSegment = 0;
|
||||
while (running) {
|
||||
try {
|
||||
SegmentPlaylist lsp = getNextSegments(segmentPlaylistUri);
|
||||
emptyPlaylistCheck(lsp);
|
||||
|
||||
// download new segments
|
||||
long downloadStart = System.currentTimeMillis();
|
||||
if (livestreamDownload) {
|
||||
downloadNewSegments(lsp, nextSegment);
|
||||
} else {
|
||||
downloadRecording(lsp);
|
||||
}
|
||||
long downloadTookMillis = System.currentTimeMillis() - downloadStart;
|
||||
|
||||
// download segments, which might have been skipped
|
||||
if (nextSegment > 0 && lsp.seq > nextSegment) {
|
||||
LOG.warn("Missed segments {} < {} in download for {}. Download took {}ms. Playlist is {}sec", nextSegment, lsp.seq, lsp.url,
|
||||
downloadTookMillis, lsp.totalDuration);
|
||||
}
|
||||
|
||||
if (livestreamDownload) {
|
||||
// split up the recording, if configured
|
||||
boolean split = splitRecording();
|
||||
if (split) {
|
||||
break;
|
||||
}
|
||||
|
||||
// wait some time until requesting the segment playlist again to not hammer the server
|
||||
waitForNewSegments(lsp, lastSegment, downloadTookMillis);
|
||||
|
||||
lastSegment = lsp.seq;
|
||||
nextSegment = lastSegment + lsp.segments.size();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} catch (HttpException e) {
|
||||
if (e.getResponseCode() == 404) {
|
||||
LOG.debug("Playlist not found (404). Model {} probably went offline", model);
|
||||
} else if (e.getResponseCode() == 403) {
|
||||
LOG.debug("Playlist access forbidden (403). Model {} probably went private or offline", model);
|
||||
} else {
|
||||
LOG.info("Unexpected error while downloading {}", model, e);
|
||||
}
|
||||
running = false;
|
||||
} catch (Exception e) {
|
||||
LOG.info("Unexpected error while downloading {}", model, e);
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
internalStop();
|
||||
}
|
||||
|
||||
private void downloadRecording(SegmentPlaylist lsp) throws IOException {
|
||||
for (String segment : lsp.segments) {
|
||||
URL segmentUrl = new URL(segment);
|
||||
SegmentDownload segmentDownload = new SegmentDownload(lsp, segmentUrl, client);
|
||||
byte[] segmentData = segmentDownload.call();
|
||||
writeSegment(segmentData);
|
||||
}
|
||||
}
|
||||
|
||||
private void downloadNewSegments(SegmentPlaylist lsp, int nextSegment) throws ExecutionException, IOException {
|
||||
int skip = nextSegment - lsp.seq;
|
||||
|
||||
// add segments to download threadpool
|
||||
Queue<Future<byte[]>> downloads = new LinkedList<>();
|
||||
if (downloadQueue.remainingCapacity() == 0) {
|
||||
LOG.warn("Download to slow for this stream. Download queue is full. Skipping segment");
|
||||
} else {
|
||||
for (String segment : lsp.segments) {
|
||||
if (!running) {
|
||||
break;
|
||||
}
|
||||
if (skip > 0) {
|
||||
skip--;
|
||||
} else {
|
||||
URL segmentUrl = new URL(segment);
|
||||
Future<byte[]> download = downloadThreadPool.submit(new SegmentDownload(lsp, segmentUrl, client));
|
||||
downloads.add(download);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFinishedSegments(downloads);
|
||||
}
|
||||
|
||||
private void writeFinishedSegments(Queue<Future<byte[]>> downloads) throws ExecutionException, IOException {
|
||||
for (Future<byte[]> downloadFuture : downloads) {
|
||||
try {
|
||||
byte[] segmentData = downloadFuture.get();
|
||||
writeSegment(segmentData);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
LOG.error("Error while downloading segment", e);
|
||||
} catch (ExecutionException e) {
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof MissingSegmentException) {
|
||||
if (model != null && !isModelOnline()) {
|
||||
LOG.debug("Error while downloading segment, because model {} is offline. Stopping now", model.getName());
|
||||
running = false;
|
||||
} else {
|
||||
LOG.debug("Segment not available, but model {} still online. Going on", Optional.ofNullable(model).map(Model::getName).orElse("n/a"));
|
||||
}
|
||||
} else if (cause instanceof HttpException) {
|
||||
HttpException he = (HttpException) cause;
|
||||
if (model != null && !isModelOnline()) {
|
||||
LOG.debug("Error {} while downloading segment, because model {} is offline. Stopping now", he.getResponseCode(), model.getName());
|
||||
running = false;
|
||||
} else {
|
||||
if (he.getResponseCode() == 404) {
|
||||
LOG.info("Playlist for {} not found [HTTP 404]. Stopping now", Optional.ofNullable(model).map(Model::getName).orElse("n/a"));
|
||||
running = false;
|
||||
} else if (he.getResponseCode() == 403) {
|
||||
LOG.info("Playlist for {} not accessible [HTTP 403]. Stopping now", Optional.ofNullable(model).map(Model::getName).orElse("n/a"));
|
||||
running = false;
|
||||
} else {
|
||||
throw he;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void writeSegment(byte[] segmentData) throws IOException {
|
||||
ffmpegStdIn.write(segmentData);
|
||||
}
|
||||
|
||||
private boolean splitRecording() {
|
||||
if (config.getSettings().splitRecordings > 0) {
|
||||
Duration recordingDuration = Duration.between(splitRecStartTime, ZonedDateTime.now());
|
||||
long seconds = recordingDuration.getSeconds();
|
||||
if (seconds >= config.getSettings().splitRecordings) {
|
||||
internalStop();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void waitForNewSegments(SegmentPlaylist lsp, int lastSegment, long downloadTookMillis) {
|
||||
try {
|
||||
long wait = 0;
|
||||
if (lastSegment == lsp.seq) {
|
||||
int timeLeftMillis = (int) (lsp.totalDuration * 1000 - downloadTookMillis);
|
||||
if (timeLeftMillis < 3000) { // we have less than 3 seconds to get the new playlist and start downloading it
|
||||
wait = 1;
|
||||
} else {
|
||||
// wait a second to be nice to the server (don't hammer it with requests)
|
||||
// 1 second seems to be a good compromise. every other calculation resulted in more missing segments
|
||||
wait = 1000;
|
||||
}
|
||||
LOG.trace("Playlist didn't change... waiting for {}ms", wait);
|
||||
} else {
|
||||
// playlist did change -> wait for at least last segment duration
|
||||
wait = 1;
|
||||
LOG.trace("Playlist changed... waiting for {}ms", wait);
|
||||
}
|
||||
Thread.sleep(wait);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
if (running) {
|
||||
LOG.error("Couldn't sleep between segment downloads. This might mess up the download!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
if (running) {
|
||||
try {
|
||||
internalStop();
|
||||
int count = 0;
|
||||
while (!downloadFinished && count++ < 60) {
|
||||
LOG.debug("Waiting for download to finish {}", model);
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
if(!downloadFinished) {
|
||||
LOG.warn("Download didn't finishe properly for model {}", model);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
LOG.error("Couldn't wait for download to finish", e);
|
||||
}
|
||||
LOG.debug("Download stopped");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
synchronized void internalStop() {
|
||||
running = false;
|
||||
if (ffmpegStdIn != null) {
|
||||
try {
|
||||
ffmpegStdIn.close();
|
||||
ffmpegStdIn = null;
|
||||
} catch (IOException e) {
|
||||
LOG.error("Couldn't close ffmpeg stream", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SegmentDownload implements Callable<byte[]> {
|
||||
private URL url;
|
||||
private HttpClient client;
|
||||
private SegmentPlaylist lsp;
|
||||
|
||||
public SegmentDownload(SegmentPlaylist lsp, URL url, HttpClient client) {
|
||||
this.lsp = lsp;
|
||||
this.url = url;
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] call() throws IOException {
|
||||
LOG.trace("Downloading segment {}", url.getFile());
|
||||
int maxTries = 3;
|
||||
for (int i = 1; i <= maxTries && running; i++) {
|
||||
Request request = new Request.Builder().url(url).addHeader("connection", "keep-alive").build();
|
||||
try (Response response = client.execute(request)) {
|
||||
if (response.isSuccessful()) {
|
||||
byte[] segment = response.body().bytes();
|
||||
if (lsp.encrypted) {
|
||||
segment = new Crypto(lsp.encryptionKeyUrl, client).decrypt(segment);
|
||||
}
|
||||
return segment;
|
||||
} else {
|
||||
throw new HttpException(response.code(), response.message());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (i == maxTries) {
|
||||
LOG.error("Error while downloading segment. Segment {} finally failed", url.getFile());
|
||||
} else {
|
||||
LOG.trace("Error while downloading segment {} on try {}", url.getFile(), i, e);
|
||||
}
|
||||
if (model != null && !isModelOnline()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new MissingSegmentException("Unable to download segment " + url.getFile() + " after " + maxTries + " tries");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isModelOnline() {
|
||||
try {
|
||||
return model.isOnline(IGNORE_CACHE);
|
||||
} catch (IOException | ExecutionException e) {
|
||||
return false;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPath(Model model) {
|
||||
String absolutePath = targetFile.getAbsolutePath();
|
||||
String recordingsDir = Config.getInstance().getSettings().recordingsDir;
|
||||
String relativePath = absolutePath.replaceFirst(Pattern.quote(recordingsDir), "");
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postprocess(Recording recording) {
|
||||
Thread.currentThread().setName("PP " + model.getName());
|
||||
try {
|
||||
runPostProcessingScript(recording);
|
||||
} catch (Exception e) {
|
||||
throw new PostProcessingException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void downloadFinishedRecording(String segmentPlaylistUri, File target, ProgressListener progressListener) throws Exception {
|
||||
if (Config.getInstance().getSettings().requireAuthentication) {
|
||||
URL u = new URL(segmentPlaylistUri);
|
||||
String path = u.getPath();
|
||||
byte[] key = Config.getInstance().getSettings().key;
|
||||
if (!Config.getInstance().getContextPath().isEmpty()) {
|
||||
path = path.substring(Config.getInstance().getContextPath().length());
|
||||
}
|
||||
String hmac = Hmac.calculate(path, key);
|
||||
segmentPlaylistUri = segmentPlaylistUri + "?hmac=" + hmac;
|
||||
}
|
||||
|
||||
startFfmpegProcess(target);
|
||||
|
||||
SegmentPlaylist segmentPlaylist = getNextSegments(segmentPlaylistUri);
|
||||
int fileCounter = 0;
|
||||
for (String segmentUrl : segmentPlaylist.segments) {
|
||||
downloadFile(segmentUrl);
|
||||
fileCounter++;
|
||||
int total = segmentPlaylist.segments.size();
|
||||
int progress = (int) (fileCounter / (double) total * 100);
|
||||
progressListener.update(progress);
|
||||
}
|
||||
|
||||
internalStop();
|
||||
}
|
||||
|
||||
private void downloadFile(String fileUri) throws IOException {
|
||||
Request request = new Request.Builder().url(fileUri).addHeader("connection", "keep-alive").build();
|
||||
try (Response response = client.execute(request)) {
|
||||
if (response.isSuccessful()) {
|
||||
InputStream in = response.body().byteStream();
|
||||
byte[] b = new byte[1024 * 100];
|
||||
int length = -1;
|
||||
while ((length = in.read(b)) >= 0) {
|
||||
ffmpegStdIn.write(b, 0, length);
|
||||
}
|
||||
} else {
|
||||
throw new HttpException(response.code(), response.message());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration getLength() {
|
||||
try {
|
||||
MovieBox movieBox = MP4Util.parseMovie(targetFile);
|
||||
double lengthInSeconds = (double) movieBox.getDuration() / movieBox.getTimescale();
|
||||
return Duration.ofSeconds((long) Math.ceil(lengthInSeconds));
|
||||
} catch (IOException e) {
|
||||
LOG.error("Couldn't determine length of MP4 file {}", getTarget(), e);
|
||||
return Duration.ofSeconds(0);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,7 +7,10 @@ import java.io.FileOutputStream;
|
|||
import java.io.IOException;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
|
@ -59,7 +62,6 @@ public class MyFreeCamsClient {
|
|||
private Cache<Integer, MyFreeCamsModel> models = CacheBuilder.newBuilder().maximumSize(4000).build();
|
||||
private Lock lock = new ReentrantLock();
|
||||
private ServerConfig serverConfig;
|
||||
@SuppressWarnings("unused")
|
||||
private String tkx;
|
||||
private Integer cxid;
|
||||
private int[] ctx;
|
||||
|
@ -518,6 +520,32 @@ public class MyFreeCamsClient {
|
|||
}
|
||||
}
|
||||
|
||||
protected void authorizeForStream(SessionState state) {
|
||||
JSONObject streamInfo = new JSONObject();
|
||||
streamInfo.put("applicationName", "NxServer");
|
||||
int userChannel = 100000000 + state.getUid();
|
||||
String phase = state.getU().getPhase() != null ? state.getU().getPhase() : "z";
|
||||
String phasePrefix = phase.equals("z") ? "" : '_' + phase;
|
||||
String streamName = "mfc" + phasePrefix + '_' + userChannel + ".f4v";
|
||||
streamInfo.put("streamName", streamName);
|
||||
streamInfo.put("sessionId", "[empty]");
|
||||
JSONObject userData = new JSONObject();
|
||||
userData.put("sessionId", sessionId);
|
||||
userData.put("password", tkx);
|
||||
userData.put("roomId", userChannel);
|
||||
userData.put("modelId", state.getUid());
|
||||
JSONArray array = new JSONArray();
|
||||
Arrays.stream(ctx).forEach(array::put);
|
||||
userData.put("vidctx", Base64.getEncoder().encodeToString(array.toString().getBytes(StandardCharsets.UTF_8)));
|
||||
userData.put("cxid", cxid);
|
||||
userData.put("mode", "DOWNLOAD");
|
||||
JSONObject authCommand = new JSONObject();
|
||||
authCommand.put("userData", userData);
|
||||
authCommand.put("streamInfo", streamInfo);
|
||||
authCommand.put("command", "auth");
|
||||
LOG.info("auth command {}", authCommand.toString(2));
|
||||
}
|
||||
|
||||
private void startKeepAlive(WebSocket ws) {
|
||||
Thread keepAlive = new Thread(() -> {
|
||||
while (running) {
|
||||
|
@ -575,16 +603,18 @@ public class MyFreeCamsClient {
|
|||
boolean dontUseDash = !Config.getInstance().getSettings().mfcUseDash;
|
||||
if (serverConfig.isOnWzObsVideoServer(state) || !serverConfig.isOnObsServer(state)) {
|
||||
// wowza server
|
||||
if (useHls || dontUseDash) {
|
||||
streamUrl = HTTPS + server + ".myfreecams.com/NxServer/ngrp:mfc" + phasePrefix + '_' + userChannel + ".f4v_mobile/playlist.m3u8";
|
||||
if (dontUseDash || useHls) {
|
||||
String nonce = Double.toString(Math.random());
|
||||
streamUrl = HTTPS + server + ".myfreecams.com/NxServer/ngrp:mfc" + phasePrefix + '_' + userChannel + ".f4v_mobile/playlist.m3u8?nc=" + nonce;
|
||||
} else {
|
||||
streamUrl = HTTPS + server + ".myfreecams.com/NxServer/ngrp:mfc" + phasePrefix + '_' + userChannel + ".f4v_desktop/manifest.mpd";
|
||||
}
|
||||
} else {
|
||||
// nginx server
|
||||
if (useHls || dontUseDash) {
|
||||
if (dontUseDash || useHls) {
|
||||
String nonce = Double.toString(Math.random());
|
||||
streamUrl = HTTPS + server + ".myfreecams.com:8444/x-hls/" + cxid + '/' + userChannel + '/' + ctxenc + "/mfc" + phasePrefix + '_' + userChannel
|
||||
+ ".m3u8";
|
||||
+ ".m3u8?nc=" + nonce;
|
||||
} else {
|
||||
streamUrl = HTTPS + server + ".myfreecams.com:8444/x-dsh/" + cxid + '/' + userChannel + '/' + ctxenc + "/mfc" + phasePrefix + '_' + userChannel
|
||||
+ ".mpd";
|
||||
|
|
|
@ -106,7 +106,7 @@ public class MyFreeCamsModel extends AbstractModel {
|
|||
}
|
||||
|
||||
private boolean isHlsStream() {
|
||||
return Optional.ofNullable(streamUrl).orElse("").endsWith("m3u8");
|
||||
return Optional.ofNullable(streamUrl).orElse("").contains(".m3u8");
|
||||
}
|
||||
|
||||
private String updateStreamUrl() {
|
||||
|
@ -329,7 +329,7 @@ public class MyFreeCamsModel extends AbstractModel {
|
|||
if(streamUrl == null) {
|
||||
updateStreamUrl();
|
||||
}
|
||||
if(streamUrl.endsWith("m3u8")) {
|
||||
if(isHlsStream()) {
|
||||
return super.createDownload();
|
||||
} else {
|
||||
return new DashDownload(getSite().getHttpClient(), streamUrl);
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 506 KiB |
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
Loading…
Reference in New Issue