From d2f490f8f603418615fb078e5ced1242d471b2e9 Mon Sep 17 00:00:00 2001 From: 0xboobface <0xboobface@gmail.com> Date: Sun, 16 Feb 2020 16:55:54 +0100 Subject: [PATCH] Add FFmpeg downloaders --- .../java/ctbrec/ui/tabs/RecordingsTab.java | 4 +- .../src/main/java/ctbrec/AbstractModel.java | 8 +- common/src/main/java/ctbrec/Settings.java | 1 + .../ctbrec/recorder/NextGenLocalRecorder.java | 2 +- .../download/hls/AbstractHlsDownload.java | 5 +- .../recorder/download/hls/FFmpegDownload.java | 136 +++++ .../recorder/download/hls/HlsDownload.java | 8 +- .../download/hls/MergedFfmpegHlsDownload.java | 488 ++++++++++++++++++ .../ctbrec/sites/mfc/MyFreeCamsClient.java | 40 +- .../ctbrec/sites/mfc/MyFreeCamsModel.java | 4 +- logo.png | Bin 0 -> 12734 bytes splash.bmp | Bin 0 -> 518522 bytes splash.png | Bin 0 -> 15352 bytes 13 files changed, 675 insertions(+), 21 deletions(-) create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/FFmpegDownload.java create mode 100644 common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java create mode 100644 logo.png create mode 100644 splash.bmp create mode 100644 splash.png diff --git a/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java b/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java index a9639059..49b2fa14 100644 --- a/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/RecordingsTab.java @@ -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 { diff --git a/common/src/main/java/ctbrec/AbstractModel.java b/common/src/main/java/ctbrec/AbstractModel.java index efee77ad..637b79c8 100644 --- a/common/src/main/java/ctbrec/AbstractModel.java +++ b/common/src/main/java/ctbrec/AbstractModel.java @@ -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()); } } } diff --git a/common/src/main/java/ctbrec/Settings.java b/common/src/main/java/ctbrec/Settings.java index 75b7ca44..28319d29 100644 --- a/common/src/main/java/ctbrec/Settings.java +++ b/common/src/main/java/ctbrec/Settings.java @@ -50,6 +50,7 @@ public class Settings { public List 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; diff --git a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java index 6554b8df..24a7df71 100644 --- a/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java +++ b/common/src/main/java/ctbrec/recorder/NextGenLocalRecorder.java @@ -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); diff --git a/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java b/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java index 073ca965..780974f0 100644 --- a/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/hls/AbstractHlsDownload.java @@ -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); } diff --git a/common/src/main/java/ctbrec/recorder/download/hls/FFmpegDownload.java b/common/src/main/java/ctbrec/recorder/download/hls/FFmpegDownload.java new file mode 100644 index 00000000..d363a645 --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/download/hls/FFmpegDownload.java @@ -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(); + } + +} diff --git a/common/src/main/java/ctbrec/recorder/download/hls/HlsDownload.java b/common/src/main/java/ctbrec/recorder/download/hls/HlsDownload.java index bacbde4d..dc962242 100644 --- a/common/src/main/java/ctbrec/recorder/download/hls/HlsDownload.java +++ b/common/src/main/java/ctbrec/recorder/download/hls/HlsDownload.java @@ -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) diff --git a/common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java b/common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java new file mode 100644 index 00000000..449ceb0e --- /dev/null +++ b/common/src/main/java/ctbrec/recorder/download/hls/MergedFfmpegHlsDownload.java @@ -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> 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 download = downloadThreadPool.submit(new SegmentDownload(lsp, segmentUrl, client)); + downloads.add(download); + } + } + } + + writeFinishedSegments(downloads); + } + + private void writeFinishedSegments(Queue> downloads) throws ExecutionException, IOException { + for (Future 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 { + 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); + } + } +} diff --git a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java index 86706869..c877ccc1 100644 --- a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java +++ b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsClient.java @@ -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 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"; diff --git a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java index 182b1643..05014a10 100644 --- a/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java +++ b/common/src/main/java/ctbrec/sites/mfc/MyFreeCamsModel.java @@ -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); diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..6861394185d0b4f22df0233b3b1e30b31f53b7b0 GIT binary patch literal 12734 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

uL{ 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 X_+GXl;DP_kV9|V{^SJcU>yQ5n^u;mq literal 0 HcmV?d00001 diff --git a/splash.bmp b/splash.bmp new file mode 100644 index 0000000000000000000000000000000000000000..68ad0c3120caa2b3382e6f7067ff49a032c33307 GIT binary patch literal 518522 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 QgQF1A;tFE5B1Xag1F*iD`2YX_ literal 0 HcmV?d00001