Add variable support for model variable to player params

This commit is contained in:
0xb00bface 2021-09-10 14:30:49 +02:00
parent 45a385be11
commit ed5d674be3
7 changed files with 254 additions and 113 deletions

View File

@ -3,6 +3,13 @@
* Fix LiveJasmin followed tab
* Add buttons to settings to delete cookies per site
* Fix bug in minimal browser
* Model placeholders can now be used for player params
${modelName}
${modelDisplayName}
${modelSanitizedName}
${modelNotes}
${siteName}
${siteSanitizedName}
4.5.3
========================

View File

@ -25,12 +25,14 @@ import ctbrec.Config;
import ctbrec.Model;
import ctbrec.OS;
import ctbrec.Recording;
import ctbrec.StringUtil;
import ctbrec.event.EventBusHolder;
import ctbrec.io.StreamRedirector;
import ctbrec.io.UrlUtil;
import ctbrec.recorder.download.StreamSource;
import ctbrec.ui.controls.Dialogs;
import ctbrec.ui.event.PlayerStartedEvent;
import ctbrec.variableexpansion.ModelVariableExpander;
import javafx.scene.Scene;
public class Player {
@ -41,24 +43,6 @@ public class Player {
private Player() {
}
private static boolean play(String url, boolean async) {
boolean singlePlayer = Config.getInstance().getSettings().singlePlayer;
try {
if (singlePlayer && playerThread != null && playerThread.isRunning()) {
playerThread.stopThread();
}
playerThread = new PlayerThread(url);
if (!async) {
playerThread.join();
}
return true;
} catch (Exception e1) {
LOG.error("Couldn't start player", e1);
return false;
}
}
public static boolean play(Recording rec) {
boolean singlePlayer = Config.getInstance().getSettings().singlePlayer;
try {
@ -85,44 +69,34 @@ public class Player {
if (singlePlayer && playerThread != null && playerThread.isRunning()) {
playerThread.stopThread();
}
String playlistUrl = getPlaylistUrl(model);
LOG.debug("Playing {}", playlistUrl);
EventBusHolder.BUS.post(new PlayerStartedEvent(model));
return Player.play(playlistUrl, async);
if (singlePlayer && playerThread != null && playerThread.isRunning()) {
playerThread.stopThread();
}
playerThread = new PlayerThread(model);
if (!async) {
playerThread.join();
}
return true;
} else {
Dialogs.showError(scene, "Room not public", "Room is currently not public", null);
return false;
}
} catch (Exception e1) {
LOG.error("Couldn't get stream information for model {}", model, e1);
Dialogs.showError(scene, "Couldn't determine stream URL", e1.getLocalizedMessage(), e1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.error("Couldn't get stream information for model {}", model, e);
Dialogs.showError(scene, "Couldn't determine stream URL", e.getLocalizedMessage(), e);
return false;
} catch (Exception e) {
LOG.error("Couldn't get stream information for model {}", model, e);
Dialogs.showError(scene, "Couldn't determine stream URL", e.getLocalizedMessage(), e);
return false;
}
}
private static String getPlaylistUrl(Model model) throws IOException, ExecutionException, ParseException, PlaylistException, JAXBException {
List<StreamSource> sources = model.getStreamSources();
Collections.sort(sources);
StreamSource best;
int maxRes = Config.getInstance().getSettings().maximumResolutionPlayer;
if (maxRes > 0 && !sources.isEmpty()) {
for (Iterator<StreamSource> iterator = sources.iterator(); iterator.hasNext();) {
StreamSource streamSource = iterator.next();
if (streamSource.height > 0 && maxRes < streamSource.height) {
LOG.trace("Res too high {} > {}", streamSource.height, maxRes);
iterator.remove();
}
}
}
if (sources.isEmpty()) {
throw new RuntimeException("No stream left in playlist, because player resolution is set to " + maxRes);
} else {
LOG.debug("{} selected {}", model.getName(), sources.get(sources.size() - 1));
best = sources.get(sources.size() - 1);
}
return best.getMediaPlaylistUrl();
}
public static void stop() {
if (playerThread != null) {
playerThread.stopThread();
@ -132,11 +106,11 @@ public class Player {
private static class PlayerThread extends Thread {
private boolean running = false;
private Process playerProcess;
private String url;
private Recording rec;
private Model model;
PlayerThread(String url) {
this.url = url;
PlayerThread(Model model) {
this.model = model;
setName(getClass().getName());
start();
}
@ -158,11 +132,16 @@ public class Player {
String[] cmdline = createCmdline(file.getAbsolutePath());
playerProcess = rt.exec(cmdline, OS.getEnvironment(), file.getParentFile());
} else {
String url = null;
if (rec != null) {
url = getRemoteRecordingUrl(rec, cfg);
} else if (model != null) {
url = getPlaylistUrl(model);
}
LOG.debug("Playing {}", url);
String[] cmdline = createCmdline(url);
expandPlaceHolders(cmdline);
LOG.debug("Player command line: {}", Arrays.toString(cmdline));
playerProcess = rt.exec(cmdline);
}
@ -179,6 +158,10 @@ public class Player {
playerProcess.waitFor();
LOG.debug("Media player finished.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.error("Error in player thread", e);
Dialogs.showError(scene, "Playback failed", "Couldn't start playback", e);
} catch (Exception e) {
LOG.error("Error in player thread", e);
Dialogs.showError(scene, "Playback failed", "Couldn't start playback", e);
@ -186,20 +169,52 @@ public class Player {
running = false;
}
private static String getPlaylistUrl(Model model) throws IOException, ExecutionException, ParseException, PlaylistException, JAXBException {
List<StreamSource> sources = model.getStreamSources();
Collections.sort(sources);
StreamSource best;
int maxRes = Config.getInstance().getSettings().maximumResolutionPlayer;
if (maxRes > 0 && !sources.isEmpty()) {
for (Iterator<StreamSource> iterator = sources.iterator(); iterator.hasNext();) {
StreamSource streamSource = iterator.next();
if (streamSource.height > 0 && maxRes < streamSource.height) {
LOG.trace("Res too high {} > {}", streamSource.height, maxRes);
iterator.remove();
}
}
}
if (sources.isEmpty()) {
throw new RuntimeException("No stream left in playlist, because player resolution is set to " + maxRes);
} else {
LOG.debug("{} selected {}", model.getName(), sources.get(sources.size() - 1));
best = sources.get(sources.size() - 1);
}
return best.getMediaPlaylistUrl();
}
private void expandPlaceHolders(String[] cmdline) {
ModelVariableExpander expander = new ModelVariableExpander(model, Config.getInstance(), null);
for (int i = 0; i < cmdline.length; i++) {
var param = cmdline[i];
param = expander.expand(param);
cmdline[i] = param;
}
}
private String[] createCmdline(String mediaSource) {
Config cfg = Config.getInstance();
String params = cfg.getSettings().mediaPlayerParams.trim();
String[] cmdline = null;
if(!params.isEmpty()) {
String[] playerArgs = params.split(" ");
if (params.isEmpty()) {
cmdline = new String[2];
} else {
String[] playerArgs = StringUtil.splitParams(params);
cmdline = new String[playerArgs.length + 2];
System.arraycopy(playerArgs, 0, cmdline, 1, playerArgs.length);
} else {
cmdline = new String[2];
}
cmdline[0] = cfg.getSettings().mediaPlayer;
cmdline[cmdline.length - 1] = mediaSource;
LOG.debug("Player command line: {}", Arrays.toString(cmdline));
return cmdline;
}

View File

@ -225,20 +225,25 @@ public class SettingsTab extends Tab implements TabSelectionListener {
var storage = new CtbrecPreferencesStorage(config);
var prefs = Preferences.of(storage,
Category.of("General",
Group.of("General", Setting.of("User-Agent", httpUserAgent),
Group.of("General",
Setting.of("User-Agent", httpUserAgent),
Setting.of("User-Agent mobile", httpUserAgentMobile),
Setting.of("Update overview interval (seconds)", overviewUpdateIntervalInSecs, "Update the thumbnail overviews every x seconds").needsRestart(),
Setting.of("Update thumbnails", updateThumbnails,
"The overviews will still be updated, but the thumbnails won't be changed. This is useful for less powerful systems."),
Setting.of("Manually select stream quality", chooseStreamQuality, "Opens a dialog to select the video resolution before recording"),
Setting.of("Enable live previews (experimental)", livePreviews), Setting.of("Enable recently watched tab", recentlyWatched).needsRestart(),
Setting.of("Enable live previews (experimental)", livePreviews),
Setting.of("Enable recently watched tab", recentlyWatched).needsRestart(),
Setting.of("Minimize to tray", minimizeToTray, "Removes the app from the task bar, if minimized"),
Setting.of("Add models from clipboard", monitorClipboard, "Monitor clipboard for model URLs and automatically add them to the recorder").needsRestart(),
Setting.of("Show confirmation dialogs", confirmationDialogs, "Show confirmation dialogs for irreversible actions"),
Setting.of("Start Tab", startTab)),
Group.of("Player", Setting.of("Player", mediaPlayer), Setting.of("Start parameters", mediaPlayerParams),
Group.of("Player",
Setting.of("Player", mediaPlayer),
Setting.of("Start parameters", mediaPlayerParams),
Setting.of("Maximum resolution (0 = unlimited)", maximumResolutionPlayer, "video height, e.g. 720 or 1080"),
Setting.of("Show \"Player Starting\" Message", showPlayerStarting), Setting.of("Start only one player at a time", singlePlayer))),
Setting.of("Show \"Player Starting\" Message", showPlayerStarting),
Setting.of("Start only one player at a time", singlePlayer))),
Category.of("Look & Feel",
Group.of("Look & Feel",
Setting.of("Colors (Base / Accent)", new ColorSettingsPane(Config.getInstance())).needsRestart(),
@ -248,10 +253,11 @@ public class SettingsTab extends Tab implements TabSelectionListener {
Setting.of("Show grid lines in tables", showGridLinesInTables, "Show grid lines in tables").needsRestart(),
Setting.of("Fast scroll speed", fastScrollSpeed, "Makes the thumbnail overviews scroll faster with the mouse wheel").needsRestart())),
Category.of("Recorder",
Group.of("Recorder", Setting.of("Recordings Directory", recordingsDir), Setting.of("Directory Structure", directoryStructure),
Group.of("Recorder",
Setting.of("Recordings Directory", recordingsDir),
Setting.of("Directory Structure", directoryStructure),
Setting.of("Split recordings after", splitAfter).converter(SplitAfterOption.converter()).onChange(this::splitValuesChanged),
Setting.of("Split recordings bigger than", splitBiggerThan).converter(SplitBiggerThanOption.converter())
.onChange(this::splitValuesChanged),
Setting.of("Split recordings bigger than", splitBiggerThan).converter(SplitBiggerThanOption.converter()).onChange(this::splitValuesChanged),
Setting.of("Restrict Resolution", resolutionRange, "Only record streams with resolution within the given range"),
Setting.of("Concurrent Recordings (0 = unlimited)", concurrentRecordings),
Setting.of("Default Priority", defaultPriority),
@ -265,23 +271,34 @@ public class SettingsTab extends Tab implements TabSelectionListener {
Setting.of("Don't record from", timeoutRecordingStartingAt),
Setting.of("Until", timeoutRecordingEndingAt)
),
Group.of("Location", Setting.of("Record Location", recordLocal).needsRestart(), Setting.of("Server", server), Setting.of("Port", port),
Group.of("Location",
Setting.of("Record Location", recordLocal).needsRestart(),
Setting.of("Server", server),
Setting.of("Port", port),
Setting.of("Path", path, "Leave empty, if you didn't change the servletContext in the server config"),
Setting.of("Download Filename", downloadFilename, "File name pattern for downloads"), Setting.of("", variablesHelpButton),
Setting.of("Download Filename", downloadFilename, "File name pattern for downloads"),
Setting.of("", variablesHelpButton),
Setting.of("Require authentication", requireAuthentication),
Setting.of("Use Secure Communication (TLS)", transportLayerSecurity))),
Category.of("Post-Processing",
Group.of("Post-Processing", Setting.of("Threads", postProcessingThreads), Setting.of("Steps", postProcessingStepPanel),
Group.of("Post-Processing",
Setting.of("Threads", postProcessingThreads),
Setting.of("Steps", postProcessingStepPanel),
Setting.of("", createHelpButton("Post-Processing Help", "http://localhost:5689/docs/PostProcessing.md")))),
Category.of("Events & Actions", new ActionSettingsPanel(recorder)), Category.of("Ignore List", ignoreList),
Category.of("Sites", siteCategories.toArray(new Category[0])),
Category.of("Proxy",
Group.of("Proxy", Setting.of("Type", proxyType).needsRestart(), Setting.of("Host", proxyHost).needsRestart(),
Setting.of("Port", proxyPort).needsRestart(), Setting.of("Username", proxyUser).needsRestart(),
Group.of("Proxy",
Setting.of("Type", proxyType).needsRestart(),
Setting.of("Host", proxyHost).needsRestart(),
Setting.of("Port", proxyPort).needsRestart(),
Setting.of("Username", proxyUser).needsRestart(),
Setting.of("Password", proxyPassword).needsRestart())),
Category.of("Advanced / Devtools",
Group.of("Networking", Setting.of("Playlist request timeout (ms)", playlistRequestTimeout, "Timeout in ms for playlist requests")),
Group.of("Logging", Setting.of("Log FFmpeg output", logFFmpegOutput, "Log FFmpeg output to files in the system's temp directory"),
Group.of("Networking",
Setting.of("Playlist request timeout (ms)", playlistRequestTimeout, "Timeout in ms for playlist requests")),
Group.of("Logging",
Setting.of("Log FFmpeg output", logFFmpegOutput, "Log FFmpeg output to files in the system's temp directory"),
Setting.of("Log missed segments", logMissedSegments,
"Write a log files in the system's temp directory to analyze missed segments")),
Group.of("hlsdl (experimental)",

View File

@ -1,7 +1,10 @@
package ctbrec;
import java.text.DecimalFormat;
import java.util.LinkedList;
import java.util.List;
import java.util.StringTokenizer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class StringUtil {
@ -191,4 +194,18 @@ public class StringUtil {
}
return a;
}
public static String[] splitParams(String params) {
Pattern p = Pattern.compile("(\"[^\"]+\"|[^\\s\"]+)");
Matcher m = p.matcher(params);
List<String> result = new LinkedList<>();
while (m.find()) {
String group = m.group();
if (group.startsWith("\"") && group.endsWith("\"")) {
group = group.substring(1, group.length() - 1);
}
result.add(group);
}
return result.toArray(new String[0]);
}
}

View File

@ -12,38 +12,31 @@ import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Function;
import ctbrec.Config;
import ctbrec.Model;
import ctbrec.ModelGroup;
import ctbrec.Recording;
import ctbrec.StringUtil;
import ctbrec.sites.Site;
import ctbrec.variableexpansion.ModelVariableExpander;
public abstract class AbstractPlaceholderAwarePostProcessor extends AbstractPostProcessor {
public String fillInPlaceHolders(String input, PostProcessingContext ctx) {
Recording rec = ctx.getRecording();
Config config = ctx.getConfig();
Optional<ModelGroup> modelGroup = Optional.ofNullable(ctx.getRecorder()).flatMap(r -> r.getModelGroup(rec.getModel()));
Map<String, Function<String, Optional<String>>> placeholderValueSuppliers = new HashMap<>();
placeholderValueSuppliers.put("modelName", r -> ofNullable(rec.getModel().getName()));
placeholderValueSuppliers.put("modelDisplayName", r -> ofNullable(rec.getModel().getDisplayName()));
placeholderValueSuppliers.put("modelSanitizedName", r -> getSanitizedName(rec.getModel()));
placeholderValueSuppliers.put("siteName", r -> ofNullable(rec.getModel().getSite()).map(Site::getName));
placeholderValueSuppliers.put("siteSanitizedName", r -> getSanitizedSiteName(rec));
placeholderValueSuppliers.put("fileSuffix", r -> getFileSuffix(rec));
placeholderValueSuppliers.put("epochSecond", r -> ofNullable(rec.getStartDate()).map(Instant::getEpochSecond).map(l -> Long.toString(l))); // NOSONAR
placeholderValueSuppliers.put("modelNotes", r -> getSanitizedModelNotes(config, rec.getModel()));
ModelVariableExpander modelExpander = new ModelVariableExpander(rec.getModel(), config, ctx.getRecorder());
placeholderValueSuppliers.putAll(modelExpander.getPlaceholderValueSuppliers());
placeholderValueSuppliers.put("recordingNotes", r -> getSanitizedRecordingNotes(rec));
placeholderValueSuppliers.put("fileSuffix", r -> getFileSuffix(rec));
placeholderValueSuppliers.put("recordingsDir", r -> Optional.of(config.getSettings().recordingsDir));
placeholderValueSuppliers.put("absolutePath", r -> Optional.of(rec.getPostProcessedFile().getAbsolutePath()));
placeholderValueSuppliers.put("absoluteParentPath", r -> Optional.of(rec.getPostProcessedFile().getParentFile().getAbsolutePath()));
placeholderValueSuppliers.put("modelGroupName", r -> modelGroup.map(ModelGroup::getName));
placeholderValueSuppliers.put("modelGroupId", r -> modelGroup.map(ModelGroup::getId).map(UUID::toString));
placeholderValueSuppliers.put("epochSecond", r -> ofNullable(rec.getStartDate()).map(Instant::getEpochSecond).map(l -> Long.toString(l))); // NOSONAR
placeholderValueSuppliers.put("utcDateTime", pattern -> replaceUtcDateTime(rec, pattern));
placeholderValueSuppliers.put("localDateTime", pattern -> replaceLocalDateTime(rec, pattern));
@ -51,15 +44,6 @@ public abstract class AbstractPlaceholderAwarePostProcessor extends AbstractPost
return output;
}
private Optional<String> getSanitizedName(Model model) {
String name = model.getSanitizedNamed();
if (StringUtil.isBlank(name)) {
return Optional.empty();
} else {
return Optional.of(name);
}
}
private String fillInPlaceHolders(String input, Map<String, Function<String, Optional<String>>> placeholderValueSuppliers) {
boolean somethingReplaced = false;
do {
@ -89,7 +73,7 @@ public abstract class AbstractPlaceholderAwarePostProcessor extends AbstractPost
Optional<String> optionalValue = placeholderValueSuppliers.getOrDefault(name, r -> Optional.of(name)).apply(expression);
String value = optionalValue.orElse(defaultValue);
StringBuilder sb = new StringBuilder(input);
String output = sb.replace(start, end+1, value).toString();
String output = sb.replace(start, end + 1, value).toString();
somethingReplaced = !Objects.equals(input, output);
input = output;
}
@ -128,15 +112,6 @@ public abstract class AbstractPlaceholderAwarePostProcessor extends AbstractPost
}
}
private Optional<String> getSanitizedSiteName(Recording rec) {
Optional<String> name = ofNullable(rec.getModel().getSite()).map(Site::getName);
if (name.isPresent()) {
return Optional.of(sanitize(name.get()));
} else {
return Optional.empty();
}
}
private Optional<String> getSanitizedRecordingNotes(Recording rec) {
Optional<String> notes = ofNullable(rec.getNote());
if (notes.isPresent()) {
@ -145,13 +120,4 @@ public abstract class AbstractPlaceholderAwarePostProcessor extends AbstractPost
return Optional.empty();
}
}
private Optional<String> getSanitizedModelNotes(Config config, Model m) {
Optional<String> notes = ofNullable(config.getModelNotes(m));
if (notes.isPresent()) {
return Optional.of(sanitize(notes.get()));
} else {
return Optional.empty();
}
}
}

View File

@ -0,0 +1,59 @@
package ctbrec.variableexpansion;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
abstract class AbstractVariableExpander {
protected Map<String, Function<String, Optional<String>>> placeholderValueSuppliers = new HashMap<>();
protected String fillInPlaceHolders(String input, Map<String, Function<String, Optional<String>>> placeholderValueSuppliers) {
boolean somethingReplaced = false;
do {
somethingReplaced = false;
int end = input.indexOf("}");
if (end > 0) {
int start = input.substring(0, end).lastIndexOf("${");
if (start >= 0) {
String placeholder = input.substring(start, end + 1);
String placeholderName = placeholder.substring(2, placeholder.length() - 1);
String defaultValue = null;
String expression = null;
int questionMark = placeholder.indexOf('?');
if (questionMark > 0) {
placeholderName = placeholder.substring(2, questionMark);
defaultValue = placeholder.substring(questionMark + 1, placeholder.length() - 1);
} else {
defaultValue = "";
}
int bracket = placeholder.indexOf('(');
if (bracket > 0) {
placeholderName = placeholder.substring(2, bracket);
expression = placeholder.substring(bracket + 1, placeholder.indexOf(')', bracket));
}
final String name = placeholderName;
Optional<String> optionalValue = placeholderValueSuppliers.getOrDefault(name, r -> Optional.of(name)).apply(expression);
String value = optionalValue.orElse(defaultValue);
StringBuilder sb = new StringBuilder(input);
String output = sb.replace(start, end+1, value).toString();
somethingReplaced = !Objects.equals(input, output);
input = output;
}
}
} while (somethingReplaced);
return input;
}
public Set<String> getPlaceholderNames() {
return placeholderValueSuppliers.keySet();
}
public Map<String, Function<String, Optional<String>>> getPlaceholderValueSuppliers() {
return placeholderValueSuppliers;
}
}

View File

@ -0,0 +1,60 @@
package ctbrec.variableexpansion;
import static ctbrec.StringUtil.*;
import static java.util.Optional.*;
import java.util.Optional;
import java.util.UUID;
import ctbrec.Config;
import ctbrec.Model;
import ctbrec.ModelGroup;
import ctbrec.StringUtil;
import ctbrec.recorder.Recorder;
import ctbrec.sites.Site;
public class ModelVariableExpander extends AbstractVariableExpander {
public ModelVariableExpander(Model model, Config config, Recorder recorder) {
Optional<ModelGroup> modelGroup = Optional.ofNullable(recorder).flatMap(r -> r.getModelGroup(model));
placeholderValueSuppliers.put("modelName", r -> ofNullable(model.getName()));
placeholderValueSuppliers.put("modelDisplayName", r -> ofNullable(model.getDisplayName()));
placeholderValueSuppliers.put("modelSanitizedName", r -> getSanitizedName(model));
placeholderValueSuppliers.put("modelNotes", r -> getSanitizedModelNotes(config, model));
placeholderValueSuppliers.put("siteName", r -> ofNullable(model.getSite()).map(Site::getName));
placeholderValueSuppliers.put("siteSanitizedName", r -> getSanitizedSiteName(model));
placeholderValueSuppliers.put("modelGroupName", r -> modelGroup.map(ModelGroup::getName));
placeholderValueSuppliers.put("modelGroupId", r -> modelGroup.map(ModelGroup::getId).map(UUID::toString));
}
public String expand(String input) {
return fillInPlaceHolders(input, placeholderValueSuppliers);
}
private Optional<String> getSanitizedName(Model model) {
String name = model.getSanitizedNamed();
if (StringUtil.isBlank(name)) {
return Optional.empty();
} else {
return Optional.of(name);
}
}
private Optional<String> getSanitizedSiteName(Model model) {
Optional<String> name = ofNullable(model.getSite()).map(Site::getName);
if (name.isPresent()) {
return Optional.of(sanitize(name.get()));
} else {
return Optional.empty();
}
}
private Optional<String> getSanitizedModelNotes(Config config, Model m) {
Optional<String> notes = ofNullable(config.getModelNotes(m));
if (notes.isPresent()) {
return Optional.of(sanitize(notes.get()));
} else {
return Optional.empty();
}
}
}