cmd = new ArrayList<>();
+ cmd.add("ffplay");
+ if (headless) {
+ cmd.add("-nodisp");
+ } else {
+ cmd.add("-fs");
+ }
+ cmd.add("-autoexit");
+ cmd.add("-loglevel");
+ cmd.add("error");
+ // FFmpeg 6+/8 default extension_picky rejects YouTube googlevideo URLs (no .ts/.m4s).
+ // Only for HLS — these options can break plain mpegts/.ts opens used in smoke tests.
+ if (isHlsPlaylistUri(playlistUri)) {
+ cmd.add("-extension_picky");
+ cmd.add("0");
+ cmd.add("-allowed_extensions");
+ cmd.add("ALL");
+ cmd.add("-allowed_segment_extensions");
+ cmd.add("ALL");
+ }
+ // -fflags nobuffer makes ffplay exit in ~200ms on short VOD ENDLIST items.
+ cmd.add("-flags");
+ cmd.add("low_delay");
+ cmd.add("-framedrop");
+ cmd.add("-sync");
+ cmd.add("audio");
+ cmd.add("-volume");
+ cmd.add(String.valueOf(Math.max(0, Math.min(100, (int) Math.round(volume * 100)))));
+ if (startAt > 0.05) {
+ cmd.add("-ss");
+ cmd.add(String.format(Locale.US, "%.3f", startAt));
+ }
+ cmd.add(playlistUri);
+
+ ProcessBuilder pb = new ProcessBuilder(cmd);
+ FfplayPcmSink.forcePulseAudioEnv(pb);
+ NativeProcessLog.configureProcessLogging(pb, "ffmpeg");
+ Process process = pb.start();
+ // Give SDL a moment; if it dies immediately the URL/env is wrong.
+ sleepQuiet(150);
+ if (!process.isAlive()) {
+ process.destroyForcibly();
+ throw new IllegalStateException("ffplay exited immediately for " + playlistUri);
+ }
+ return process;
+ }
+
+ private static boolean isHlsPlaylistUri(String playlistUri) {
+ if (playlistUri == null) {
+ return false;
+ }
+ String lower = playlistUri.toLowerCase(Locale.ROOT);
+ int q = lower.indexOf('?');
+ if (q >= 0) {
+ lower = lower.substring(0, q);
+ }
+ return lower.endsWith(".m3u8") || lower.endsWith(".m3u");
+ }
+
+ private void destroyFfplay() {
+ Process process;
+ synchronized (processLock) {
+ process = ffplayProcess;
+ ffplayProcess = null;
+ }
+ if (process == null) {
+ return;
+ }
+ process.destroy();
+ try {
+ if (!process.waitFor(500, java.util.concurrent.TimeUnit.MILLISECONDS)) {
+ process.destroyForcibly();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ process.destroyForcibly();
+ }
+ }
+
+ private void markEndedAndRefresh(String reason, long myEpoch) {
+ if (epoch.get() != myEpoch || userStopped.get() || uri == null) {
+ return;
+ }
+ long now = System.nanoTime();
+ if (now - lastEndedNotifyNanos < EOS_DEBOUNCE_NS) {
+ log.debug("Skipping duplicate HLS end notify ({})", reason);
+ return;
+ }
+ lastEndedNotifyNanos = now;
+ ended.set(true);
+ double dur = durationSeconds();
+ if (dur > 0) {
+ positionBaseSeconds = dur;
+ }
+ log.info("HLS ended ({}), requesting playlist refresh for {}", reason, uri);
+ onEnded.run();
+ }
+
+ void setOnEnded(Runnable onEnded) {
+ this.onEnded = onEnded == null ? () -> {} : onEnded;
+ }
+
+ private static void sleepQuiet(long ms) {
+ try {
+ Thread.sleep(ms);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private static double clampVolume(double volumeLinear) {
+ return Math.max(0.0, Math.min(1.0, volumeLinear));
+ }
+
+ private static String withCacheBuster(String playlistUri, long epoch) {
+ if (playlistUri == null || playlistUri.isBlank()) {
+ return playlistUri;
+ }
+ String lower = playlistUri.toLowerCase(Locale.ROOT);
+ if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
+ return playlistUri;
+ }
+ String sep = playlistUri.contains("?") ? "&" : "?";
+ return playlistUri + sep + "_airplay_epoch=" + epoch;
+ }
+}
diff --git a/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FfplayPcmSink.java b/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FfplayPcmSink.java
new file mode 100644
index 00000000..856411da
--- /dev/null
+++ b/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/FfplayPcmSink.java
@@ -0,0 +1,183 @@
+package com.github.serezhka.airplay.player.ffmpeg;
+
+import com.github.serezhka.airplay.player.support.NativeProcessLog;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Plays interleaved little-endian S16 PCM via {@code ffplay} (Pulse/PipeWire-friendly on Linux).
+ * Prefer this over {@link javax.sound.sampled.SourceDataLine}, which often binds ALSA HDMI and blocks.
+ *
+ * FFmpeg 8+ pcm demuxer no longer accepts {@code -ac}; use {@code -ch_layout} instead.
+ * Writes go through a bounded queue so a stalled Pulse sink cannot inflate AirPlay latency.
+ */
+final class FfplayPcmSink implements AutoCloseable {
+
+ private static final Logger log = LoggerFactory.getLogger(FfplayPcmSink.class);
+ /** Drop when more than ~150ms of PCM is waiting (stereo S16 @ rate). */
+ private static final double MAX_QUEUED_SECONDS = 0.15;
+
+ private final Process process;
+ private final OutputStream out;
+ private final int sampleRate;
+ private final int channels;
+ private final ArrayBlockingQueue queue;
+ private final Thread writer;
+ private final AtomicBoolean closed = new AtomicBoolean();
+ private volatile long droppedChunks;
+
+ private FfplayPcmSink(Process process, int sampleRate, int channels) {
+ this.process = process;
+ this.out = process.getOutputStream();
+ this.sampleRate = sampleRate;
+ this.channels = channels;
+ int bytesPerSec = Math.max(1, sampleRate) * Math.max(1, channels) * 2;
+ int maxChunks = Math.max(4, (int) Math.ceil(MAX_QUEUED_SECONDS * bytesPerSec / 2048.0));
+ this.queue = new ArrayBlockingQueue<>(maxChunks);
+ this.writer = new Thread(this::drainLoop, "ffplay-pcm-writer");
+ this.writer.setDaemon(true);
+ this.writer.start();
+ }
+
+ static FfplayPcmSink start(int sampleRate, int channels) throws IOException {
+ int rate = sampleRate > 0 ? sampleRate : 44_100;
+ int ch = channels > 0 ? channels : 2;
+ String layout = ch <= 1 ? "mono" : "stereo";
+ // FFmpeg 7+/8 pcm demuxer: -sample_rate / -ch_layout (not -ar / -ac).
+ ProcessBuilder pb = new ProcessBuilder(
+ "ffplay", "-nodisp", "-loglevel", "error",
+ "-fflags", "nobuffer", "-flags", "low_delay",
+ "-f", "s16le", "-sample_rate", String.valueOf(rate), "-ch_layout", layout,
+ "-i", "pipe:0");
+ forcePulseAudioEnv(pb);
+ // Smaller SDL buffer → less path latency on Pulse/xrdp (Linux only).
+ if (System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("linux")) {
+ pb.environment().put("SDL_AUDIOBUFFERSIZE", "1024");
+ }
+ NativeProcessLog.configureProcessLogging(pb, "ffmpeg");
+ Process process = pb.start();
+ FfplayPcmSink sink = new FfplayPcmSink(process, rate, ch);
+ // Keep the pipe from looking like EOF before the first real samples arrive.
+ byte[] priming = new byte[rate / 50 * ch * 2]; // ~20ms silence
+ sink.write(priming, 0, priming.length);
+ if (!process.isAlive()) {
+ sink.close();
+ throw new IOException("ffplay PCM sink exited immediately (check -ch_layout / ffmpeg version)");
+ }
+ log.info("ffplay PCM sink started rate={}Hz ch_layout={} pid={}", rate, layout, process.pid());
+ return sink;
+ }
+
+ int sampleRate() {
+ return sampleRate;
+ }
+
+ int channels() {
+ return channels;
+ }
+
+ boolean isAlive() {
+ return !closed.get() && process.isAlive();
+ }
+
+ void write(byte[] pcm, int offset, int length) throws IOException {
+ if (closed.get() || !process.isAlive()) {
+ throw new IOException("ffplay PCM sink dead");
+ }
+ if (length <= 0) {
+ return;
+ }
+ byte[] chunk = Arrays.copyOfRange(pcm, offset, offset + length);
+ while (!queue.offer(chunk)) {
+ // Drop oldest queued audio rather than block the decode/display loop.
+ if (queue.poll() != null) {
+ droppedChunks++;
+ } else {
+ break;
+ }
+ }
+ }
+
+ private void drainLoop() {
+ try {
+ while (!closed.get() && process.isAlive()) {
+ byte[] chunk = queue.poll(50, TimeUnit.MILLISECONDS);
+ if (chunk == null) {
+ continue;
+ }
+ out.write(chunk);
+ // Avoid flush-per-chunk (adds tens of ms of syscall latency on Pulse/xrdp).
+ if (queue.isEmpty()) {
+ out.flush();
+ }
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (IOException e) {
+ log.debug("ffplay PCM writer stopped: {}", e.toString());
+ }
+ }
+
+ @Override
+ public void close() {
+ if (!closed.compareAndSet(false, true)) {
+ return;
+ }
+ long dropped = droppedChunks;
+ if (dropped > 0) {
+ log.info("ffplay PCM sink closed after dropping {} queued chunk(s) (latency cap)", dropped);
+ }
+ queue.clear();
+ writer.interrupt();
+ try {
+ writer.join(500);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ try {
+ out.close();
+ } catch (IOException ignored) {
+ // ignore
+ }
+ process.destroy();
+ try {
+ if (!process.waitFor(500, TimeUnit.MILLISECONDS)) {
+ process.destroyForcibly();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ process.destroyForcibly();
+ }
+ }
+
+ /** Prefer Pulse/PipeWire over ALSA HDMI when ffplay is spawned from Java on Linux. */
+ static void forcePulseAudioEnv(ProcessBuilder pb) {
+ Map env = pb.environment();
+ String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
+ if (os.contains("linux")) {
+ env.putIfAbsent("SDL_AUDIODRIVER", "pulse");
+ env.putIfAbsent("SDL_VIDEODRIVER", "x11");
+ }
+ copyEnv(env, "PULSE_SERVER");
+ copyEnv(env, "PULSE_SINK");
+ copyEnv(env, "DISPLAY");
+ copyEnv(env, "XAUTHORITY");
+ copyEnv(env, "XDG_RUNTIME_DIR");
+ }
+
+ private static void copyEnv(Map env, String key) {
+ String value = System.getenv(key);
+ if (value != null && !value.isBlank()) {
+ env.put(key, value);
+ }
+ }
+}
diff --git a/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/LibavAacDecoder.java b/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/LibavAacDecoder.java
new file mode 100644
index 00000000..6b108a43
--- /dev/null
+++ b/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/LibavAacDecoder.java
@@ -0,0 +1,238 @@
+package com.github.serezhka.airplay.player.ffmpeg;
+
+import com.github.serezhka.airplay.protocol.media.AudioStreamInfo;
+import org.bytedeco.ffmpeg.avcodec.AVCodec;
+import org.bytedeco.ffmpeg.avcodec.AVCodecContext;
+import org.bytedeco.ffmpeg.avcodec.AVPacket;
+import org.bytedeco.ffmpeg.avutil.AVChannelLayout;
+import org.bytedeco.ffmpeg.avutil.AVFrame;
+import org.bytedeco.ffmpeg.swresample.SwrContext;
+import org.bytedeco.javacpp.BytePointer;
+import org.bytedeco.javacpp.PointerPointer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+import static org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_AAC;
+import static org.bytedeco.ffmpeg.global.avcodec.av_new_packet;
+import static org.bytedeco.ffmpeg.global.avcodec.av_packet_alloc;
+import static org.bytedeco.ffmpeg.global.avcodec.av_packet_free;
+import static org.bytedeco.ffmpeg.global.avcodec.av_packet_unref;
+import static org.bytedeco.ffmpeg.global.avcodec.avcodec_alloc_context3;
+import static org.bytedeco.ffmpeg.global.avcodec.avcodec_find_decoder;
+import static org.bytedeco.ffmpeg.global.avcodec.avcodec_free_context;
+import static org.bytedeco.ffmpeg.global.avcodec.avcodec_open2;
+import static org.bytedeco.ffmpeg.global.avcodec.avcodec_receive_frame;
+import static org.bytedeco.ffmpeg.global.avcodec.avcodec_send_packet;
+import static org.bytedeco.ffmpeg.global.avutil.AVERROR_EOF;
+import static org.bytedeco.ffmpeg.global.avutil.AV_SAMPLE_FMT_S16;
+import static org.bytedeco.ffmpeg.global.avutil.av_frame_alloc;
+import static org.bytedeco.ffmpeg.global.avutil.av_frame_free;
+import static org.bytedeco.ffmpeg.global.avutil.av_free;
+import static org.bytedeco.ffmpeg.global.avutil.av_get_bytes_per_sample;
+import static org.bytedeco.ffmpeg.global.avutil.av_malloc;
+import static org.bytedeco.ffmpeg.global.avutil.av_sample_fmt_is_planar;
+import static org.bytedeco.ffmpeg.global.swresample.swr_alloc_set_opts2;
+import static org.bytedeco.ffmpeg.global.swresample.swr_convert;
+import static org.bytedeco.ffmpeg.global.swresample.swr_free;
+import static org.bytedeco.ffmpeg.global.swresample.swr_init;
+
+/**
+ * Decodes AirPlay AAC-LC / AAC-ELD raw frames with bundled libav
+ * (system {@code ffplay -f aac} does not accept raw ELD and FFmpeg 8 broke {@code -c:a aac}).
+ */
+final class LibavAacDecoder implements AutoCloseable {
+
+ private static final Logger log = LoggerFactory.getLogger(LibavAacDecoder.class);
+ private static final int INPUT_PADDING = 64;
+ private static final int AVERROR_EAGAIN = -11;
+
+ /** Same ASC as GStreamer aac-eld appsrc ({@code codec_data=f8e85000}). */
+ private static final byte[] ASC_ELD_44100_2 = hex("f8e85000");
+ private static final byte[] ASC_LC_44100_2 = hex("1210");
+
+ private final int sampleRate;
+ private final int channels;
+ private AVCodecContext codecCtx;
+ private AVPacket packet;
+ private AVFrame frame;
+ private SwrContext swr;
+ private BytePointer swrOut;
+ private int swrOutSamples;
+
+ LibavAacDecoder(AudioStreamInfo info) {
+ this.sampleRate = sampleRate(info);
+ this.channels = channels(info);
+ boolean eld = info != null && info.getCompressionType() == AudioStreamInfo.CompressionType.AAC_ELD;
+ open(eld ? ASC_ELD_44100_2 : ASC_LC_44100_2, eld ? "AAC-ELD" : "AAC-LC");
+ }
+
+ int sampleRate() {
+ return sampleRate;
+ }
+
+ int channels() {
+ return channels;
+ }
+
+ private void open(byte[] asc, String label) {
+ AVCodec codec = avcodec_find_decoder(AV_CODEC_ID_AAC);
+ if (codec == null || codec.isNull()) {
+ throw new IllegalStateException("libav has no AAC decoder");
+ }
+ codecCtx = avcodec_alloc_context3(codec);
+ BytePointer extradata = new BytePointer(av_malloc(asc.length + INPUT_PADDING));
+ extradata.limit(asc.length + INPUT_PADDING);
+ extradata.put(asc);
+ for (int i = 0; i < INPUT_PADDING; i++) {
+ extradata.put(asc.length + i, (byte) 0);
+ }
+ codecCtx.extradata(extradata);
+ codecCtx.extradata_size(asc.length);
+ codecCtx.sample_rate(sampleRate);
+ codecCtx.ch_layout().nb_channels(channels);
+ if (avcodec_open2(codecCtx, codec, (PointerPointer>) null) < 0) {
+ throw new IllegalStateException("avcodec_open2(" + label + ") failed");
+ }
+ packet = av_packet_alloc();
+ frame = av_frame_alloc();
+ log.info("libav {} decoder open rate={}Hz channels={}", label, sampleRate, channels);
+ }
+
+ synchronized void decode(byte[] aacPacket, FfplayPcmSink sink) throws IOException {
+ if (codecCtx == null || sink == null || aacPacket == null || aacPacket.length == 0) {
+ return;
+ }
+ av_packet_unref(packet);
+ if (av_new_packet(packet, aacPacket.length) < 0) {
+ return;
+ }
+ try {
+ packet.data().put(aacPacket);
+ int send = avcodec_send_packet(codecCtx, packet);
+ if (send < 0 && send != AVERROR_EAGAIN && send != AVERROR_EOF) {
+ log.debug("AAC send_packet err={}", send);
+ return;
+ }
+ while (true) {
+ int rec = avcodec_receive_frame(codecCtx, frame);
+ if (rec == AVERROR_EAGAIN || rec == AVERROR_EOF) {
+ break;
+ }
+ if (rec < 0) {
+ break;
+ }
+ byte[] pcm = toS16Interleaved(frame);
+ if (pcm != null && pcm.length > 0) {
+ sink.write(pcm, 0, pcm.length);
+ }
+ }
+ } finally {
+ av_packet_unref(packet);
+ }
+ }
+
+ private byte[] toS16Interleaved(AVFrame src) {
+ int srcFmt = src.format();
+ int nbSamples = src.nb_samples();
+ int ch = src.ch_layout().nb_channels() > 0 ? src.ch_layout().nb_channels() : channels;
+ if (srcFmt == AV_SAMPLE_FMT_S16 && av_sample_fmt_is_planar(srcFmt) == 0) {
+ byte[] out = new byte[nbSamples * ch * 2];
+ src.data(0).get(out);
+ return out;
+ }
+ ensureSwr(src, ch);
+ int outBytes = nbSamples * ch * av_get_bytes_per_sample(AV_SAMPLE_FMT_S16);
+ if (swrOut == null || swrOutSamples < nbSamples) {
+ if (swrOut != null) {
+ av_free(swrOut);
+ }
+ swrOut = new BytePointer(av_malloc(outBytes));
+ swrOutSamples = nbSamples;
+ }
+ PointerPointer outPlanes = new PointerPointer<>(1);
+ outPlanes.put(0, swrOut);
+ int converted = swr_convert(swr, outPlanes, nbSamples, src.data(), nbSamples);
+ outPlanes.deallocate();
+ if (converted <= 0) {
+ return null;
+ }
+ byte[] out = new byte[converted * ch * 2];
+ swrOut.position(0).limit(out.length).asByteBuffer().get(out);
+ return out;
+ }
+
+ private void ensureSwr(AVFrame src, int ch) {
+ if (swr != null && !swr.isNull()) {
+ return;
+ }
+ swr = new SwrContext();
+ AVChannelLayout inLayout = new AVChannelLayout(src.ch_layout());
+ AVChannelLayout outLayout = new AVChannelLayout();
+ outLayout.nb_channels(ch);
+ int err = swr_alloc_set_opts2(swr,
+ outLayout, AV_SAMPLE_FMT_S16, sampleRate,
+ inLayout, src.format(), src.sample_rate(),
+ 0, null);
+ if (err < 0 || swr_init(swr) < 0) {
+ throw new IllegalStateException("swr_init failed for AAC");
+ }
+ }
+
+ @Override
+ public synchronized void close() {
+ if (swr != null) {
+ swr_free(swr);
+ swr = null;
+ }
+ if (swrOut != null) {
+ av_free(swrOut);
+ swrOut = null;
+ }
+ if (frame != null) {
+ av_frame_free(frame);
+ frame = null;
+ }
+ if (packet != null) {
+ av_packet_free(packet);
+ packet = null;
+ }
+ if (codecCtx != null) {
+ avcodec_free_context(codecCtx);
+ codecCtx = null;
+ }
+ }
+
+ private static int sampleRate(AudioStreamInfo info) {
+ String name = formatName(info);
+ if (name.contains("48000")) {
+ return 48_000;
+ }
+ if (name.contains("24000")) {
+ return 24_000;
+ }
+ if (name.contains("16000")) {
+ return 16_000;
+ }
+ return 44_100;
+ }
+
+ private static int channels(AudioStreamInfo info) {
+ String name = formatName(info);
+ return name.endsWith("_1") ? 1 : 2;
+ }
+
+ private static String formatName(AudioStreamInfo info) {
+ return info == null || info.getAudioFormat() == null ? "" : info.getAudioFormat().name();
+ }
+
+ private static byte[] hex(String s) {
+ int n = s.length() / 2;
+ byte[] out = new byte[n];
+ for (int i = 0; i < n; i++) {
+ out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
+ }
+ return out;
+ }
+}
diff --git a/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/LibavAlacDecoder.java b/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/LibavAlacDecoder.java
new file mode 100644
index 00000000..53ff3fea
--- /dev/null
+++ b/player/ffmpeg/src/main/java/com/github/serezhka/airplay/player/ffmpeg/LibavAlacDecoder.java
@@ -0,0 +1,251 @@
+package com.github.serezhka.airplay.player.ffmpeg;
+
+import com.github.serezhka.airplay.protocol.media.AudioStreamInfo;
+import org.bytedeco.ffmpeg.avcodec.AVCodec;
+import org.bytedeco.ffmpeg.avcodec.AVCodecContext;
+import org.bytedeco.ffmpeg.avcodec.AVPacket;
+import org.bytedeco.ffmpeg.avutil.AVChannelLayout;
+import org.bytedeco.ffmpeg.avutil.AVFrame;
+import org.bytedeco.ffmpeg.swresample.SwrContext;
+import org.bytedeco.javacpp.BytePointer;
+import org.bytedeco.javacpp.PointerPointer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+
+import static org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_ALAC;
+import static org.bytedeco.ffmpeg.global.avcodec.av_new_packet;
+import static org.bytedeco.ffmpeg.global.avcodec.av_packet_alloc;
+import static org.bytedeco.ffmpeg.global.avcodec.av_packet_free;
+import static org.bytedeco.ffmpeg.global.avcodec.av_packet_unref;
+import static org.bytedeco.ffmpeg.global.avcodec.avcodec_alloc_context3;
+import static org.bytedeco.ffmpeg.global.avcodec.avcodec_find_decoder;
+import static org.bytedeco.ffmpeg.global.avcodec.avcodec_free_context;
+import static org.bytedeco.ffmpeg.global.avcodec.avcodec_open2;
+import static org.bytedeco.ffmpeg.global.avcodec.avcodec_receive_frame;
+import static org.bytedeco.ffmpeg.global.avcodec.avcodec_send_packet;
+import static org.bytedeco.ffmpeg.global.avutil.AVERROR_EOF;
+import static org.bytedeco.ffmpeg.global.avutil.AV_SAMPLE_FMT_S16;
+import static org.bytedeco.ffmpeg.global.avutil.av_frame_alloc;
+import static org.bytedeco.ffmpeg.global.avutil.av_frame_free;
+import static org.bytedeco.ffmpeg.global.avutil.av_free;
+import static org.bytedeco.ffmpeg.global.avutil.av_get_bytes_per_sample;
+import static org.bytedeco.ffmpeg.global.avutil.av_malloc;
+import static org.bytedeco.ffmpeg.global.avutil.av_sample_fmt_is_planar;
+import static org.bytedeco.ffmpeg.global.swresample.swr_alloc_set_opts2;
+import static org.bytedeco.ffmpeg.global.swresample.swr_convert;
+import static org.bytedeco.ffmpeg.global.swresample.swr_free;
+import static org.bytedeco.ffmpeg.global.swresample.swr_init;
+
+/**
+ * Decodes AirPlay ALAC packets with bundled libav (FFmpeg 8 removed {@code -f alac} demuxer).
+ */
+final class LibavAlacDecoder implements AutoCloseable {
+
+ private static final Logger log = LoggerFactory.getLogger(LibavAlacDecoder.class);
+ private static final int INPUT_PADDING = 64;
+ /** POSIX EAGAIN negated — {@code AVERROR(EAGAIN)}. */
+ private static final int AVERROR_EAGAIN = -11;
+
+ private final int sampleRate;
+ private final int channels;
+ private AVCodecContext codecCtx;
+ private AVPacket packet;
+ private AVFrame frame;
+ private SwrContext swr;
+ private BytePointer swrOut;
+ private int swrOutSamples;
+
+ LibavAlacDecoder(AudioStreamInfo info) {
+ this.sampleRate = sampleRate(info);
+ this.channels = channels(info);
+ int bitDepth = bitDepth(info);
+ int framesPerPacket = info != null && info.getSamplesPerFrame() > 0 ? info.getSamplesPerFrame() : 352;
+ open(bitDepth, framesPerPacket);
+ }
+
+ int sampleRate() {
+ return sampleRate;
+ }
+
+ int channels() {
+ return channels;
+ }
+
+ private void open(int bitDepth, int framesPerPacket) {
+ AVCodec codec = avcodec_find_decoder(AV_CODEC_ID_ALAC);
+ if (codec == null || codec.isNull()) {
+ throw new IllegalStateException("libav has no ALAC decoder");
+ }
+ codecCtx = avcodec_alloc_context3(codec);
+ byte[] cookie = magicCookie(framesPerPacket, bitDepth, channels, sampleRate);
+ BytePointer extradata = new BytePointer(av_malloc(cookie.length + INPUT_PADDING));
+ extradata.limit(cookie.length + INPUT_PADDING);
+ extradata.put(cookie);
+ for (int i = 0; i < INPUT_PADDING; i++) {
+ extradata.put(cookie.length + i, (byte) 0);
+ }
+ codecCtx.extradata(extradata);
+ codecCtx.extradata_size(cookie.length);
+ codecCtx.sample_rate(sampleRate);
+ AVChannelLayout layout = codecCtx.ch_layout();
+ layout.nb_channels(channels);
+ if (avcodec_open2(codecCtx, codec, (PointerPointer>) null) < 0) {
+ throw new IllegalStateException("avcodec_open2(ALAC) failed");
+ }
+ packet = av_packet_alloc();
+ frame = av_frame_alloc();
+ log.info("libav ALAC decoder open rate={}Hz channels={} frames/packet={}",
+ sampleRate, channels, framesPerPacket);
+ }
+
+ synchronized void decode(byte[] alacPacket, FfplayPcmSink sink) throws IOException {
+ if (codecCtx == null || sink == null || alacPacket == null || alacPacket.length == 0) {
+ return;
+ }
+ av_packet_unref(packet);
+ if (av_new_packet(packet, alacPacket.length) < 0) {
+ log.debug("ALAC av_new_packet failed");
+ return;
+ }
+ try {
+ packet.data().put(alacPacket);
+ int send = avcodec_send_packet(codecCtx, packet);
+ if (send < 0 && send != AVERROR_EAGAIN && send != AVERROR_EOF) {
+ log.debug("ALAC send_packet err={}", send);
+ return;
+ }
+ while (true) {
+ int rec = avcodec_receive_frame(codecCtx, frame);
+ if (rec == AVERROR_EAGAIN || rec == AVERROR_EOF) {
+ break;
+ }
+ if (rec < 0) {
+ log.debug("ALAC receive_frame err={}", rec);
+ break;
+ }
+ byte[] pcm = toS16Interleaved(frame);
+ if (pcm != null && pcm.length > 0) {
+ sink.write(pcm, 0, pcm.length);
+ }
+ }
+ } finally {
+ av_packet_unref(packet);
+ }
+ }
+
+ private byte[] toS16Interleaved(AVFrame src) {
+ int srcFmt = src.format();
+ int nbSamples = src.nb_samples();
+ int ch = src.ch_layout().nb_channels() > 0 ? src.ch_layout().nb_channels() : channels;
+ if (srcFmt == AV_SAMPLE_FMT_S16 && av_sample_fmt_is_planar(srcFmt) == 0) {
+ int bytes = nbSamples * ch * 2;
+ byte[] out = new byte[bytes];
+ src.data(0).get(out);
+ return out;
+ }
+ ensureSwr(src, ch);
+ int outBytes = nbSamples * ch * av_get_bytes_per_sample(AV_SAMPLE_FMT_S16);
+ if (swrOut == null || swrOutSamples < nbSamples) {
+ if (swrOut != null) {
+ av_free(swrOut);
+ }
+ swrOut = new BytePointer(av_malloc(outBytes));
+ swrOutSamples = nbSamples;
+ }
+ PointerPointer outPlanes = new PointerPointer<>(1);
+ outPlanes.put(0, swrOut);
+ int converted = swr_convert(swr, outPlanes, nbSamples, src.data(), nbSamples);
+ outPlanes.deallocate();
+ if (converted <= 0) {
+ return null;
+ }
+ byte[] out = new byte[converted * ch * 2];
+ swrOut.position(0).limit(out.length).asByteBuffer().get(out);
+ return out;
+ }
+
+ private void ensureSwr(AVFrame src, int ch) {
+ if (swr != null && !swr.isNull()) {
+ return;
+ }
+ swr = new SwrContext();
+ AVChannelLayout inLayout = new AVChannelLayout(src.ch_layout());
+ AVChannelLayout outLayout = new AVChannelLayout();
+ outLayout.nb_channels(ch);
+ int err = swr_alloc_set_opts2(swr,
+ outLayout, AV_SAMPLE_FMT_S16, sampleRate,
+ inLayout, src.format(), src.sample_rate(),
+ 0, null);
+ if (err < 0 || swr_init(swr) < 0) {
+ throw new IllegalStateException("swr_init failed for ALAC");
+ }
+ }
+
+ @Override
+ public synchronized void close() {
+ if (swr != null) {
+ swr_free(swr);
+ swr = null;
+ }
+ if (swrOut != null) {
+ av_free(swrOut);
+ swrOut = null;
+ }
+ if (frame != null) {
+ av_frame_free(frame);
+ frame = null;
+ }
+ if (packet != null) {
+ av_packet_free(packet);
+ packet = null;
+ }
+ if (codecCtx != null) {
+ // avcodec_free_context frees extradata — do not av_free it again.
+ avcodec_free_context(codecCtx);
+ codecCtx = null;
+ }
+ }
+
+ private static byte[] magicCookie(int frameLength, int bitDepth, int channels, int sampleRate) {
+ ByteBuffer cookie = ByteBuffer.allocate(36).order(ByteOrder.BIG_ENDIAN);
+ cookie.putInt(36);
+ cookie.put("alac".getBytes(StandardCharsets.US_ASCII));
+ cookie.putInt(0);
+ cookie.putInt(frameLength);
+ cookie.put((byte) 0);
+ cookie.put((byte) bitDepth);
+ cookie.put((byte) 40);
+ cookie.put((byte) 10);
+ cookie.put((byte) 14);
+ cookie.put((byte) channels);
+ cookie.putShort((short) 0x00ff);
+ cookie.putInt(0);
+ cookie.putInt(0);
+ cookie.putInt(sampleRate);
+ return cookie.array();
+ }
+
+ private static int sampleRate(AudioStreamInfo info) {
+ String name = formatName(info);
+ return name.contains("48000") ? 48_000 : 44_100;
+ }
+
+ private static int channels(AudioStreamInfo info) {
+ String name = formatName(info);
+ return name.endsWith("_1") ? 1 : 2;
+ }
+
+ private static int bitDepth(AudioStreamInfo info) {
+ String name = formatName(info);
+ return name.contains("_24_") ? 24 : 16;
+ }
+
+ private static String formatName(AudioStreamInfo info) {
+ return info == null || info.getAudioFormat() == null ? "" : info.getAudioFormat().name();
+ }
+}
diff --git a/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipelineSmokeTest.java b/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipelineSmokeTest.java
new file mode 100644
index 00000000..1b294019
--- /dev/null
+++ b/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipelineSmokeTest.java
@@ -0,0 +1,168 @@
+package com.github.serezhka.airplay.player.ffmpeg;
+
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Self-test for FFmpeg HLS pause/resume/seek + PCM sink without a phone.
+ * Needs {@code ffmpeg} and {@code ffplay} on PATH.
+ */
+class FfmpegHlsPipelineSmokeTest {
+
+ @TempDir
+ Path temp;
+
+ @Test
+ @Timeout(90)
+ void pauseResumeSeekAndAudioSinkStayAlive() throws Exception {
+ Assumptions.assumeTrue(ffmpegAvailable(), "ffmpeg not available");
+ Assumptions.assumeTrue(ffplayAvailable(), "ffplay not available");
+
+ Path media = temp.resolve("smoke.ts");
+ generateSmokeTs(media);
+ Assumptions.assumeTrue(Files.size(media) > 1000, "failed to generate smoke.ts");
+
+ System.setProperty("airplay.ffmpeg.hls.headless", "true");
+ String uri = media.toUri().toString();
+
+ FfmpegHlsPipeline hls = new FfmpegHlsPipeline();
+ try {
+ hls.start(uri, 1.0);
+ awaitPosition(hls, 0.4, 12_000);
+ assertTrue(hls.audioSinkAlive(), "ffplay PCM sink died early (check -ch_layout / FFmpeg 8)");
+
+ double beforePause = hls.currentPositionSeconds();
+ hls.pause();
+ Thread.sleep(800);
+ double duringPause = hls.currentPositionSeconds();
+ assertTrue(Math.abs(duringPause - beforePause) < 0.5,
+ "position should stay put while paused: " + beforePause + " -> " + duringPause);
+
+ hls.resume();
+ awaitAdvance(hls, duringPause + 0.3, 12_000);
+ assertTrue(hls.audioSinkAlive(), "ffplay PCM sink died after resume");
+
+ double beforeSeek = hls.currentPositionSeconds();
+ double target = Math.max(0.5, beforeSeek + 1.0);
+ hls.seek(target);
+ awaitNear(hls, target, 2.0, 12_000);
+ awaitAdvance(hls, target + 0.15, 12_000);
+ awaitAudioAlive(hls, 8_000);
+ assertTrue(hls.audioSinkAlive(), "ffplay PCM sink died after seek");
+ } finally {
+ hls.stop();
+ System.clearProperty("airplay.ffmpeg.hls.headless");
+ }
+ }
+
+ @Test
+ @Timeout(90)
+ void extensionlessSegmentUrlsPlayWithPickyOff() throws Exception {
+ Assumptions.assumeTrue(ffmpegAvailable(), "ffmpeg not available");
+ Assumptions.assumeTrue(ffplayAvailable(), "ffplay not available");
+
+ Path media = temp.resolve("seg.bin");
+ generateSmokeTs(media);
+ Assumptions.assumeTrue(Files.size(media) > 1000, "failed to generate seg.bin");
+
+ Path playlist = temp.resolve("pl.m3u8");
+ Files.writeString(playlist, """
+ #EXTM3U
+ #EXT-X-VERSION:3
+ #EXT-X-TARGETDURATION:7
+ #EXTINF:6.0,
+ seg.bin
+ #EXT-X-ENDLIST
+ """);
+
+ System.setProperty("airplay.ffmpeg.hls.headless", "true");
+ FfmpegHlsPipeline hls = new FfmpegHlsPipeline();
+ try {
+ hls.start(playlist.toUri().toString(), 1.0);
+ awaitPosition(hls, 0.3, 15_000);
+ assertTrue(hls.audioSinkAlive(),
+ "ffplay must play extensionless HLS segments (-extension_picky 0)");
+ } finally {
+ hls.stop();
+ System.clearProperty("airplay.ffmpeg.hls.headless");
+ }
+ }
+
+ private static boolean ffmpegAvailable() {
+ return toolAvailable("ffmpeg");
+ }
+
+ private static boolean ffplayAvailable() {
+ return toolAvailable("ffplay");
+ }
+
+ private static boolean toolAvailable(String tool) {
+ try {
+ Process p = new ProcessBuilder(tool, "-version").redirectErrorStream(true).start();
+ return p.waitFor(5, TimeUnit.SECONDS) && p.exitValue() == 0;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private static void generateSmokeTs(Path out) throws Exception {
+ Process p = new ProcessBuilder(
+ "ffmpeg", "-y",
+ "-f", "lavfi", "-i", "testsrc=duration=6:size=320x240:rate=30",
+ "-f", "lavfi", "-i", "sine=frequency=440:duration=6",
+ "-c:v", "libx264", "-pix_fmt", "yuv420p", "-tune", "zerolatency",
+ "-c:a", "aac", "-ac", "2", "-ar", "44100",
+ "-shortest", "-f", "mpegts", out.toAbsolutePath().toString()
+ ).redirectErrorStream(true).start();
+ String output = new String(p.getInputStream().readAllBytes());
+ boolean finished = p.waitFor(45, TimeUnit.SECONDS);
+ Assumptions.assumeTrue(finished && p.exitValue() == 0,
+ "ffmpeg failed generating smoke.ts: " + output);
+ }
+
+ private static void awaitAudioAlive(FfmpegHlsPipeline hls, long timeoutMs) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + timeoutMs;
+ while (System.currentTimeMillis() < deadline) {
+ if (hls.audioSinkAlive()) {
+ return;
+ }
+ Thread.sleep(100);
+ }
+ }
+
+ private static void awaitPosition(FfmpegHlsPipeline hls, double min, long timeoutMs) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + timeoutMs;
+ while (System.currentTimeMillis() < deadline) {
+ if (hls.currentPositionSeconds() >= min) {
+ return;
+ }
+ Thread.sleep(100);
+ }
+ assertTrue(false, "position never reached " + min + ", last=" + hls.currentPositionSeconds());
+ }
+
+ private static void awaitAdvance(FfmpegHlsPipeline hls, double min, long timeoutMs) throws InterruptedException {
+ awaitPosition(hls, min, timeoutMs);
+ }
+
+ private static void awaitNear(FfmpegHlsPipeline hls, double target, double slack, long timeoutMs)
+ throws InterruptedException {
+ long deadline = System.currentTimeMillis() + timeoutMs;
+ while (System.currentTimeMillis() < deadline) {
+ double pos = hls.currentPositionSeconds();
+ if (Math.abs(pos - target) <= slack || pos >= target - 0.1) {
+ return;
+ }
+ Thread.sleep(100);
+ }
+ assertTrue(false, "seek did not land near " + target + ", last=" + hls.currentPositionSeconds());
+ }
+}
diff --git a/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipelineTest.java b/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipelineTest.java
new file mode 100644
index 00000000..2b350051
--- /dev/null
+++ b/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/FfmpegHlsPipelineTest.java
@@ -0,0 +1,152 @@
+package com.github.serezhka.airplay.player.ffmpeg;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FfmpegHlsPipelineTest {
+
+ private final FfmpegHlsPipeline hls = new FfmpegHlsPipeline();
+
+ @AfterEach
+ void tearDown() {
+ hls.stop();
+ }
+
+ @Test
+ void notesEndListDuration() {
+ hls.noteMediaDuration(6.0);
+ hls.noteMediaDuration(13.6);
+ assertEquals(13.6, hls.durationSeconds(), 0.001);
+ hls.noteMediaDuration(10.0);
+ assertEquals(13.6, hls.durationSeconds(), 0.001);
+ }
+
+ @Test
+ void ignoresAbsurdSeekTargets() {
+ hls.noteMediaDuration(30);
+ hls.seek(-1);
+ hls.seek(Double.NaN);
+ hls.seek(Double.POSITIVE_INFINITY);
+ hls.seek(86_400.0 * 8);
+ assertEquals(0, hls.currentPositionSeconds(), 0.001);
+ }
+
+ @Test
+ void pauseFreezesReportedPosition() throws Exception {
+ System.setProperty("airplay.ffmpeg.hls.headless", "true");
+ try {
+ hls.start("http://127.0.0.1:9/missing.m3u8", 1.0);
+ hls.noteMediaDuration(60);
+ hls.seek(5);
+ assertEquals(5, hls.currentPositionSeconds(), 0.1);
+ hls.pause();
+ assertTrue(hls.isPaused());
+ double pausedAt = hls.currentPositionSeconds();
+ Thread.sleep(200);
+ assertEquals(pausedAt, hls.currentPositionSeconds(), 0.05);
+ hls.resume();
+ assertFalse(hls.isPaused());
+ } finally {
+ System.clearProperty("airplay.ffmpeg.hls.headless");
+ }
+ }
+
+ @Test
+ void demuxPtsResetDoesNotJumpReportedPosition() throws Exception {
+ System.setProperty("airplay.ffmpeg.hls.headless", "true");
+ try {
+ hls.start("http://127.0.0.1:9/missing.m3u8", 1.0);
+ hls.noteMediaDuration(60);
+ hls.seek(5.0);
+ // Position is frozen while ffplay is down; seek still updates the base.
+ assertEquals(5.0, hls.currentPositionSeconds(), 0.1);
+
+ // Simulate HLS/TS segment PTS restart near zero (the phone scrubber bug).
+ hls.noteDemuxTimestampMicros(64_944L);
+ double after = hls.currentPositionSeconds();
+ assertEquals(5.0, after, 0.1,
+ "reported position must not jump on demux PTS reset: " + after);
+ } finally {
+ System.clearProperty("airplay.ffmpeg.hls.headless");
+ }
+ }
+
+ @Test
+ void scrubJumpFixtureDocumentsHistoricalDemuxPtsResets() throws Exception {
+ // Seed from Kali dump 20260917-074700 — offline proof of the scrubber jumps.
+ try (var in = FfmpegHlsPipelineTest.class.getResourceAsStream(
+ "/fixtures/scrub-jump-20260917.json")) {
+ assertTrue(in != null, "missing fixtures/scrub-jump-20260917.json");
+ String json = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
+ assertTrue(json.contains("\"jump_count\": 4") || json.contains("\"jump_count\":4"), json);
+ assertTrue(json.contains("2.986557162"));
+ assertTrue(json.contains("0.510844"));
+ // Historical dump had backward jumps within the same duration — regression seed.
+ assertTrue(json.contains("\"from_pos\": 2.986557162") || json.contains("\"from_pos\":2.986557162"));
+ }
+ }
+
+ @Test
+ void startWithInitialSeekDoesNotRequireFollowUpSeek() throws Exception {
+ System.setProperty("airplay.ffmpeg.hls.headless", "true");
+ try {
+ hls.start("http://127.0.0.1:9/missing.m3u8", 1.0, 42.5);
+ hls.noteMediaDuration(120);
+ assertEquals(42.5, hls.currentPositionSeconds(), 0.2);
+ } finally {
+ System.clearProperty("airplay.ffmpeg.hls.headless");
+ }
+ }
+
+ @Test
+ void playerBakesSeekBeforePlaylistIntoStart() throws Exception {
+ FFmpegPlayer player = new FFmpegPlayer();
+ System.setProperty("airplay.ffmpeg.hls.headless", "true");
+ try {
+ player.onSeek(33.0);
+ player.onPlaylist("http://127.0.0.1:9/missing.m3u8");
+ player.onPlaylistContent(
+ "mlhls://localhost/itag/229/mediadata.m3u8",
+ "#EXTM3U\n#EXTINF:60.0,\nseg.ts\n#EXT-X-ENDLIST\n");
+ Thread.sleep(80);
+ assertEquals(33.0, player.info().position(), 0.5);
+ } finally {
+ player.onPlaylistRemoved();
+ System.clearProperty("airplay.ffmpeg.hls.headless");
+ }
+ }
+
+ @Test
+ void endOfStreamNotifiesLifecycleOnce() {
+ AtomicInteger ends = new AtomicInteger();
+ hls.setOnEnded(ends::incrementAndGet);
+ hls.noteMediaDuration(1.0);
+ assertEquals(0, ends.get());
+ hls.stop();
+ assertEquals(0, ends.get());
+ }
+
+ @Test
+ void playerParsesEndListDurationIntoPlaybackInfo() throws Exception {
+ FFmpegPlayer player = new FFmpegPlayer();
+ System.setProperty("airplay.ffmpeg.hls.headless", "true");
+ try {
+ player.onPlaylist("http://127.0.0.1:9/missing.m3u8");
+ player.onPlaylistContent(
+ "mlhls://localhost/itag/229/mediadata.m3u8",
+ "#EXTM3U\n#EXTINF:6.5,\nad.ts\n#EXTINF:7.1,\nad2.ts\n#EXT-X-ENDLIST\n");
+ Thread.sleep(50);
+ var info = player.info();
+ assertTrue(info.duration() >= 13.5 && info.duration() <= 13.7);
+ } finally {
+ player.onPlaylistRemoved();
+ System.clearProperty("airplay.ffmpeg.hls.headless");
+ }
+ }
+}
diff --git a/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/PlayerBoundaryTest.java b/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/PlayerBoundaryTest.java
new file mode 100644
index 00000000..e433ffca
--- /dev/null
+++ b/player/ffmpeg/src/test/java/com/github/serezhka/airplay/player/ffmpeg/PlayerBoundaryTest.java
@@ -0,0 +1,23 @@
+package com.github.serezhka.airplay.player.ffmpeg;
+
+import com.tngtech.archunit.core.importer.ImportOption;
+import com.tngtech.archunit.junit.AnalyzeClasses;
+import com.tngtech.archunit.junit.ArchTest;
+import com.tngtech.archunit.lang.ArchRule;
+
+import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
+
+@AnalyzeClasses(
+ packages = "com.github.serezhka.airplay.player.ffmpeg",
+ importOptions = ImportOption.DoNotIncludeTests.class
+)
+class PlayerBoundaryTest {
+
+ @ArchTest
+ static final ArchRule doesNotReachIntoServerOrOtherPlayers = noClasses()
+ .should().dependOnClassesThat().resideInAnyPackage(
+ "com.github.serezhka.airplay.server.internal..",
+ "com.github.serezhka.airplay.player.gstreamer..",
+ "com.github.serezhka.airplay.player.dump.."
+ );
+}
diff --git a/player/ffmpeg/src/test/resources/fixtures/scrub-jump-20260917.json b/player/ffmpeg/src/test/resources/fixtures/scrub-jump-20260917.json
new file mode 100644
index 00000000..8a26e086
--- /dev/null
+++ b/player/ffmpeg/src/test/resources/fixtures/scrub-jump-20260917.json
@@ -0,0 +1 @@
+{"description": "ffmpeg HLS demux PTS reset caused phone scrubber jumps", "jump_count": 4, "jumps": [{"from_pos": 2.986557162, "to_pos": 0.510844, "duration": 12.04, "t": "10:47:14.895"}, {"from_pos": 2.994765678, "to_pos": 0.064944, "duration": 165.92, "t": "10:47:35.018"}, {"from_pos": 1.995004854, "to_pos": 1.3932, "duration": 138.3, "t": "10:47:45.546"}, {"from_pos": 4.996720254, "to_pos": 1.021677, "duration": 959.6999589999998, "t": "10:48:01.588"}], "positions": [{"duration": 0.0, "position": 0.0, "rate": 1.0}, {"duration": 151.799996, "position": 0.89402874, "rate": 1.0}, {"duration": 151.799996, "position": 0.907976002, "rate": 1.0}, {"duration": 151.799996, "position": 0.983119234, "rate": 1.0}, {"duration": 151.799996, "position": 1.984988415, "rate": 1.0}, {"duration": 151.799996, "position": 2.98077066, "rate": 1.0}, {"duration": 151.799996, "position": 4.017914972, "rate": 1.0}, {"duration": 151.799996, "position": 5.085261579, "rate": 1.0}, {"duration": 151.799996, "position": 5.982951526, "rate": 1.0}, {"duration": 151.799996, "position": 6.985018126, "rate": 1.0}, {"duration": 0.0, "position": 0.0, "rate": 1.0}, {"duration": 12.04, "position": 0.942960036, "rate": 1.0}, {"duration": 12.04, "position": 0.953508918, "rate": 1.0}, {"duration": 12.04, "position": 1.02742166, "rate": 1.0}, {"duration": 12.04, "position": 1.990979588, "rate": 1.0}, {"duration": 12.04, "position": 2.986557162, "rate": 1.0}, {"duration": 12.04, "position": 0.510844, "rate": 1.0}, {"duration": 12.04, "position": 0.96, "rate": 1.0}, {"duration": 12.04, "position": 2.461311, "rate": 1.0}, {"duration": 12.04, "position": 2.96, "rate": 1.0}, {"duration": 12.04, "position": 4.458233, "rate": 1.0}, {"duration": 12.04, "position": 4.96, "rate": 1.0}, {"duration": 12.04, "position": 5.76, "rate": 1.0}, {"duration": 12.04, "position": 7.476822, "rate": 1.0}, {"duration": 12.04, "position": 8.452066, "rate": 1.0}, {"duration": 12.04, "position": 9.473744, "rate": 1.0}, {"duration": 12.04, "position": 10.448977, "rate": 1.0}, {"duration": 12.04, "position": 10.96, "rate": 1.0}, {"duration": 12.04, "position": 12.04, "rate": 0.0}, {"duration": 12.04, "position": 12.04, "rate": 0.0}, {"duration": 12.04, "position": 12.04, "rate": 0.0}, {"duration": 0.0, "position": 0.0, "rate": 1.0}, {"duration": 165.92, "position": 0.923355048, "rate": 1.0}, {"duration": 165.92, "position": 0.931285213, "rate": 1.0}, {"duration": 165.92, "position": 0.993752094, "rate": 1.0}, {"duration": 165.92, "position": 1.996178888, "rate": 1.0}, {"duration": 165.92, "position": 2.994765678, "rate": 1.0}, {"duration": 165.92, "position": 0.064944, "rate": 1.0}, {"duration": 165.92, "position": 1.1795, "rate": 1.0}, {"duration": 165.92, "position": 2.247611, "rate": 1.0}, {"duration": 165.92, "position": 3.269288, "rate": 1.0}, {"duration": 165.92, "position": 4.337411, "rate": 1.0}, {"duration": 165.92, "position": 5.359088, "rate": 1.0}, {"duration": 0.0, "position": 0.0, "rate": 1.0}, {"duration": 138.3, "position": 0.964500252, "rate": 1.0}, {"duration": 138.3, "position": 0.970958584, "rate": 1.0}, {"duration": 138.3, "position": 0.999812858, "rate": 1.0}, {"duration": 138.3, "position": 1.995004854, "rate": 1.0}, {"duration": 138.3, "position": 1.3932, "rate": 1.0}, {"duration": 138.3, "position": 1.4, "rate": 1.0}, {"duration": 138.3, "position": 2.739955, "rate": 1.0}, {"duration": 138.3, "position": 3.761633, "rate": 1.0}, {"duration": 138.3, "position": 4.736866, "rate": 1.0}, {"duration": 138.3, "position": 5.758544, "rate": 1.0}, {"duration": 138.3, "position": 6.733777, "rate": 1.0}, {"duration": 138.3, "position": 7.755455, "rate": 1.0}, {"duration": 138.3, "position": 8.366666, "rate": 1.0}, {"duration": 0.0, "position": 0.0, "rate": 1.0}, {"duration": 959.6999589999998, "position": 0.943730253, "rate": 1.0}, {"duration": 959.6999589999998, "position": 0.96500214, "rate": 1.0}, {"duration": 959.6999589999998, "position": 0.997000027, "rate": 1.0}, {"duration": 959.6999589999998, "position": 1.993893058, "rate": 1.0}, {"duration": 959.6999589999998, "position": 3.000267109, "rate": 1.0}, {"duration": 959.6999589999998, "position": 3.997169087, "rate": 1.0}, {"duration": 959.6999589999998, "position": 4.996720254, "rate": 1.0}, {"duration": 959.6999589999998, "position": 1.021677, "rate": 1.0}, {"duration": 959.6999589999998, "position": 2.043355, "rate": 1.0}, {"duration": 959.6999589999998, "position": 2.633333, "rate": 1.0}, {"duration": 959.6999589999998, "position": 4.086711, "rate": 1.0}, {"duration": 959.6999589999998, "position": 5.108388, "rate": 1.0}, {"duration": 959.6999589999998, "position": 5.990744, "rate": 1.0}]}
\ No newline at end of file
diff --git a/player/gstreamer/build.gradle.kts b/player/gstreamer/build.gradle.kts
index 2d535438..f164728c 100644
--- a/player/gstreamer/build.gradle.kts
+++ b/player/gstreamer/build.gradle.kts
@@ -3,12 +3,15 @@ plugins {
}
dependencies {
- implementation(projects.lib)
+ implementation(projects.protocol)
+ implementation(projects.player.support)
implementation(projects.server)
implementation(libs.slf4j.api)
implementation(libs.dd.plist)
implementation(libs.bundles.jna.full)
implementation(libs.bundles.gstreamer)
+
+ testImplementation(libs.archunit.junit5)
}
tasks.named("test") {
diff --git a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipeline.java b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipeline.java
new file mode 100644
index 00000000..9713c779
--- /dev/null
+++ b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipeline.java
@@ -0,0 +1,449 @@
+package com.github.serezhka.airplay.player.gstreamer;
+
+import com.sun.jna.Native;
+import lombok.extern.slf4j.Slf4j;
+import org.freedesktop.gstreamer.Bus;
+import org.freedesktop.gstreamer.BusSyncReply;
+import org.freedesktop.gstreamer.Element;
+import org.freedesktop.gstreamer.ElementFactory;
+import org.freedesktop.gstreamer.Format;
+import org.freedesktop.gstreamer.Gst;
+import org.freedesktop.gstreamer.Pipeline;
+import org.freedesktop.gstreamer.State;
+import org.freedesktop.gstreamer.event.SeekFlags;
+import org.freedesktop.gstreamer.event.SeekType;
+import org.freedesktop.gstreamer.interfaces.VideoOverlay;
+
+import javax.swing.JFrame;
+import java.util.EnumSet;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * HLS URI playback via {@code playbin3} (fallback {@code playbin}).
+ *
+ * Modern {@code hlsdemux2} needs a streams-aware context; plain {@code uridecodebin} does not
+ * provide one. A custom demux graph is a follow-up.
+ */
+@Slf4j
+final class GstHlsPipeline {
+
+ private final ScheduledExecutorService seekScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "hls-seek-retry");
+ t.setDaemon(true);
+ return t;
+ });
+
+ private volatile Runnable onEnded = () -> {};
+ private Pipeline pipeline;
+ private Element videoSink;
+ private JFrame window;
+ private String uri;
+ private volatile Double pendingSeekSeconds;
+ private final AtomicInteger seekAttempts = new AtomicInteger();
+ private ScheduledFuture> seekRetry;
+ private ScheduledFuture> endWatch;
+ private volatile boolean paused;
+ private volatile boolean ended;
+ private volatile double positionBaseSeconds;
+ private volatile long positionAnchorNanos;
+ private volatile double playlistDurationSeconds;
+ private volatile long lastEndedNotifyNanos;
+ private volatile long startedAtNanos;
+ /** Wall-clock position must not run until GST reports a real media clock (avoids black-screen EOS). */
+ private volatile boolean mediaClockTrusted;
+
+ void start(String playlistUri, double volumeLinear) {
+ stop();
+ uri = playlistUri;
+ paused = false;
+ ended = false;
+ positionBaseSeconds = 0;
+ positionAnchorNanos = System.nanoTime();
+ startedAtNanos = System.nanoTime();
+ lastEndedNotifyNanos = 0;
+ mediaClockTrusted = false;
+
+ boolean headless = Boolean.parseBoolean(System.getProperty("airplay.gst.hls.headless", "false"));
+ GstVideoSinkFactory.Result display = null;
+ if (headless) {
+ videoSink = ElementFactory.make("fakesink", "hls-sink");
+ // Keep clock sync in headless smoke tests too.
+ videoSink.set("sync", true);
+ videoSink.set("async", false);
+ } else {
+ // sync=true: share pipeline clock with playbin audio (avoids free-run desync).
+ display = GstVideoSinkFactory.create("hls", true);
+ videoSink = display.sink();
+ }
+
+ String launch = ElementFactory.find("playbin3") != null ? "playbin3 name=hls" : "playbin name=hls";
+ pipeline = (Pipeline) Gst.parseLaunch(launch);
+ pipeline.set("uri", playlistUri);
+ pipeline.set("volume", clampVolume(volumeLinear));
+ pipeline.set("video-sink", videoSink);
+ log.info("HLS pipeline using {} headless={}", launch.split(" ")[0], headless);
+
+ if (!headless && display != null && display.overlay() && display.canvas() != null) {
+ window = GstFullscreenWindow.create(display.canvas());
+ GstFullscreenWindow.show(window);
+ Element overlaySink = GstVideoSinkFactory.overlayTarget(videoSink, "hls-sink");
+ VideoOverlay overlay = VideoOverlay.wrap(overlaySink);
+ long hwnd = Native.getComponentID(display.canvas());
+ pipeline.getBus().setSyncHandler(message -> {
+ if (!VideoOverlay.isPrepareWindowHandleMessage(message)) {
+ return BusSyncReply.PASS;
+ }
+ // Never invokeAndWait(EDT) from a GST sync handler — deadlocks d3d11 on Windows.
+ overlay.setWindowHandle(hwnd);
+ return BusSyncReply.DROP;
+ });
+ }
+
+ pipeline.getBus().connect((Bus.EOS) source -> {
+ if (uri == null) {
+ return;
+ }
+ markEndedAndRefresh("EOS");
+ });
+ pipeline.getBus().connect((Bus.ERROR) (source, code, message) -> {
+ log.error("HLS pipeline error: code={} message={}", code, message);
+ // hlsdemux2 "Invalid manifest" after ad often never delivers bus EOS — treat as end.
+ if (uri != null && message != null && message.toLowerCase().contains("manifest")) {
+ markEndedAndRefresh("ERROR " + message);
+ }
+ });
+ pipeline.getBus().connect((Bus.WARNING) (source, code, message) ->
+ log.warn("HLS pipeline warning: code={} message={}", code, message));
+ pipeline.getBus().connect((Bus.ASYNC_DONE) source -> {
+ tryPendingSeek("async-done");
+ ensurePlaying("async-done");
+ });
+ pipeline.getBus().connect((Bus.DURATION_CHANGED) source -> tryPendingSeek("duration"));
+ pipeline.getBus().connect((Bus.STATE_CHANGED) (source, old, current, pending) -> {
+ if (source == pipeline && current == State.PLAYING) {
+ tryPendingSeek("playing");
+ }
+ });
+
+ pipeline.play();
+ tryPendingSeek("start");
+ // playbin3 + hlsdemux2 often never posts bus EOS for short VOD ENDLIST ads;
+ // poll playlist duration vs position (same idea as the FFmpeg player ENDLIST path).
+ endWatch = seekScheduler.scheduleAtFixedRate(this::checkPositionAtEnd, 400, 200, TimeUnit.MILLISECONDS);
+ log.info("HLS pipeline started uri={}", playlistUri);
+ }
+
+ void stop() {
+ cancelSeekRetry();
+ cancelEndWatch();
+ pendingSeekSeconds = null;
+ seekAttempts.set(0);
+ paused = false;
+ ended = false;
+ positionBaseSeconds = 0;
+ positionAnchorNanos = 0;
+ playlistDurationSeconds = 0;
+ lastEndedNotifyNanos = 0;
+ startedAtNanos = 0;
+ mediaClockTrusted = false;
+ uri = null;
+ if (pipeline != null) {
+ try {
+ pipeline.setState(State.NULL);
+ } catch (Throwable t) {
+ log.debug("HLS setState(NULL) failed: {}", t.toString());
+ }
+ try {
+ pipeline.dispose();
+ } catch (Throwable t) {
+ log.debug("HLS dispose failed: {}", t.toString());
+ }
+ pipeline = null;
+ }
+ videoSink = null;
+ if (window != null) {
+ GstFullscreenWindow.hide(window);
+ window = null;
+ }
+ }
+
+ void pause() {
+ if (pipeline == null || ended) {
+ return;
+ }
+ double gstPos = querySeconds(false);
+ positionBaseSeconds = gstPos > 0 ? gstPos : currentPositionSeconds();
+ paused = true;
+ pipeline.pause();
+ log.info("HLS paused at {}s", positionBaseSeconds);
+ }
+
+ void resume() {
+ if (pipeline == null) {
+ return;
+ }
+ boolean wasPaused = paused;
+ ended = false;
+ paused = false;
+ positionAnchorNanos = System.nanoTime();
+ double pos = positionBaseSeconds;
+ pipeline.play();
+ // playbin3 often stays frozen after PAUSED→PLAYING without a flush seek.
+ // Only seek when leaving pause — never on redundant rate=1 while already playing.
+ if (wasPaused && pos > 0.05) {
+ pendingSeekSeconds = pos;
+ seekAttempts.set(0);
+ tryPendingSeek("resume");
+ }
+ log.info("HLS resumed from {}s (wasPaused={})", pos, wasPaused);
+ }
+
+ void seek(double positionSeconds) {
+ if (positionSeconds < 0 || Double.isNaN(positionSeconds) || Double.isInfinite(positionSeconds)) {
+ return;
+ }
+ // Guard against CLOCK_TIME_NONE /overflow-style targets (seen as ~2.5e6 hours in demux).
+ if (positionSeconds > 86_400.0 * 7) {
+ log.warn("Ignoring absurd HLS seek to {}s", positionSeconds);
+ return;
+ }
+ ended = false;
+ positionBaseSeconds = positionSeconds;
+ positionAnchorNanos = System.nanoTime();
+ paused = false;
+ if (pipeline != null) {
+ pipeline.play();
+ }
+ pendingSeekSeconds = positionSeconds;
+ seekAttempts.set(0);
+ tryPendingSeek("request");
+ }
+
+ void noteMediaDuration(double seconds) {
+ if (seconds > playlistDurationSeconds) {
+ playlistDurationSeconds = seconds;
+ }
+ }
+
+ void setVolume(double volumeLinear) {
+ if (pipeline != null) {
+ pipeline.set("volume", clampVolume(volumeLinear));
+ }
+ }
+
+ boolean isActive() {
+ return pipeline != null || uri != null;
+ }
+
+ double currentPositionSeconds() {
+ if (ended) {
+ double dur = durationSeconds();
+ return dur > 0 ? dur : positionBaseSeconds;
+ }
+ double pos = rawPositionSeconds();
+ checkPositionAtEnd(pos);
+ return ended ? durationSeconds() : pos;
+ }
+
+ /** Raw pipeline clock position (0 if unknown) — for smoke tests. */
+ double pipelinePositionSeconds() {
+ return querySeconds(false);
+ }
+
+ double durationSeconds() {
+ // Playlist ENDLIST sum is the item length YouTube expects. GST duration for HLS ads
+ // is often hours (wrong demux timeline) and must not win.
+ if (playlistDurationSeconds > 0) {
+ return playlistDurationSeconds;
+ }
+ double gst = querySeconds(true);
+ if (gst > 0 && gst <= 600) {
+ return gst;
+ }
+ return 0;
+ }
+
+ boolean isPaused() {
+ return paused || ended;
+ }
+
+ /** True after VOD/EOS — {@code /playback-info} should still report rate=1 (playing at end). */
+ boolean isEnded() {
+ return ended;
+ }
+
+ private void checkPositionAtEnd() {
+ if (ended || uri == null || paused) {
+ return;
+ }
+ checkPositionAtEnd(rawPositionSeconds());
+ }
+
+ private void checkPositionAtEnd(double pos) {
+ if (ended || uri == null || paused || !mediaClockTrusted) {
+ return;
+ }
+ double playlist = playlistDurationSeconds;
+ // Need a real ENDLIST length and a bit of play time so a bad open/seek isn't EOS at t=0.
+ if (playlist <= 0.5 || startedAtNanos == 0) {
+ return;
+ }
+ if (System.nanoTime() - startedAtNanos < 1_500_000_000L) {
+ return;
+ }
+ if (pos >= playlist - 0.05) {
+ markEndedAndRefresh("position-at-end");
+ }
+ }
+
+ private double rawPositionSeconds() {
+ double playlist = playlistDurationSeconds;
+ double gst = querySeconds(false);
+ // Ignore GST positions past the VOD length — hlsdemux2 sometimes uses a huge media clock.
+ if (gst > 0 && (playlist <= 0 || gst <= playlist + 1.0)) {
+ mediaClockTrusted = true;
+ return gst;
+ }
+ // Until the demux reports a real position, do not invent progress from wall-clock —
+ // that produced black-screen "playback" and fake EOS on the first YouTube ad.
+ if (!mediaClockTrusted) {
+ return 0;
+ }
+ if (paused || positionAnchorNanos == 0) {
+ return positionBaseSeconds;
+ }
+ double elapsed = (System.nanoTime() - positionAnchorNanos) / 1_000_000_000.0;
+ double wall = Math.max(0, positionBaseSeconds + elapsed);
+ return playlist > 0 ? Math.min(wall, playlist) : wall;
+ }
+
+ private void markEndedAndRefresh(String reason) {
+ long now = System.nanoTime();
+ if (now - lastEndedNotifyNanos < 1_500_000_000L) {
+ log.debug("Skipping duplicate HLS end notify ({})", reason);
+ return;
+ }
+ lastEndedNotifyNanos = now;
+ ended = true;
+ cancelEndWatch();
+ double dur = durationSeconds();
+ if (dur > 0) {
+ positionBaseSeconds = dur;
+ }
+ // Local player stop at item end.
+ // Do not call Playback.onPause — that emits reverse "paused"
+ // and YouTube treats it as a user pause (blocks playlistRemove).
+ if (pipeline != null) {
+ try {
+ pipeline.pause();
+ } catch (Throwable t) {
+ log.debug("HLS pause-at-end failed: {}", t.toString());
+ }
+ }
+ paused = true;
+ log.info("HLS ended ({}), requesting playlist refresh for {}", reason, uri);
+ onEnded.run();
+ }
+
+ void setOnEnded(Runnable onEnded) {
+ this.onEnded = onEnded == null ? () -> {} : onEnded;
+ }
+
+ private void cancelEndWatch() {
+ ScheduledFuture> future = endWatch;
+ endWatch = null;
+ if (future != null) {
+ future.cancel(false);
+ }
+ }
+
+ private void ensurePlaying(String reason) {
+ if (pipeline == null || paused) {
+ return;
+ }
+ pipeline.play();
+ log.debug("HLS ensure PLAYING ({})", reason);
+ }
+
+ private void tryPendingSeek(String reason) {
+ Double seek = pendingSeekSeconds;
+ Pipeline pipe = pipeline;
+ if (seek == null || pipe == null) {
+ return;
+ }
+ if (seek < 0.05 && ("start".equals(reason) || reason.startsWith("retry-"))) {
+ pendingSeekSeconds = null;
+ seekAttempts.set(0);
+ cancelSeekRetry();
+ positionBaseSeconds = 0;
+ positionAnchorNanos = System.nanoTime();
+ log.debug("Skipping no-op HLS seek to {}s ({})", seek, reason);
+ return;
+ }
+ long ns = (long) (seek * 1_000_000_000L);
+ boolean ok = pipe.seek(
+ 1.0,
+ Format.TIME,
+ EnumSet.of(SeekFlags.FLUSH, SeekFlags.KEY_UNIT),
+ SeekType.SET,
+ ns,
+ SeekType.NONE,
+ -1);
+ log.info("HLS seek to {}s -> {} ({})", seek, ok, reason);
+ if (ok) {
+ pendingSeekSeconds = null;
+ seekAttempts.set(0);
+ cancelSeekRetry();
+ positionBaseSeconds = seek;
+ positionAnchorNanos = System.nanoTime();
+ if (!paused) {
+ pipe.play();
+ }
+ return;
+ }
+ int attempt = seekAttempts.incrementAndGet();
+ if (attempt > 40) {
+ log.warn("Giving up HLS seek to {}s after {} attempts", seek, attempt);
+ pendingSeekSeconds = null;
+ cancelSeekRetry();
+ return;
+ }
+ cancelSeekRetry();
+ seekRetry = seekScheduler.schedule(() -> tryPendingSeek("retry-" + attempt), 250, TimeUnit.MILLISECONDS);
+ }
+
+ private void cancelSeekRetry() {
+ ScheduledFuture> future = seekRetry;
+ seekRetry = null;
+ if (future != null) {
+ future.cancel(false);
+ }
+ }
+
+ private double querySeconds(boolean duration) {
+ Pipeline pipe = pipeline;
+ if (pipe == null) {
+ return 0;
+ }
+ try {
+ long nanos = duration
+ ? pipe.queryDuration(TimeUnit.NANOSECONDS)
+ : pipe.queryPosition(TimeUnit.NANOSECONDS);
+ if (nanos <= 0 || nanos == Long.MAX_VALUE) {
+ return 0;
+ }
+ return nanos / 1_000_000_000.0;
+ } catch (Exception e) {
+ return 0;
+ }
+ }
+
+ private static double clampVolume(double volumeLinear) {
+ return Math.max(0.0, Math.min(1.0, volumeLinear));
+ }
+}
+
diff --git a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstPlayer.java b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstPlayer.java
index 90066cd3..b9c20357 100644
--- a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstPlayer.java
+++ b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstPlayer.java
@@ -1,30 +1,37 @@
package com.github.serezhka.airplay.player.gstreamer;
-import com.github.serezhka.airplay.lib.AudioStreamInfo;
-import com.github.serezhka.airplay.lib.AppLogs;
-import com.github.serezhka.airplay.lib.HlsLifecycle;
-import com.github.serezhka.airplay.lib.VideoStreamInfo;
-import com.github.serezhka.airplay.server.AirPlayConsumer;
+import com.github.serezhka.airplay.protocol.media.AudioStreamInfo;
+import com.github.serezhka.airplay.player.support.NativeProcessLog;
+import com.github.serezhka.airplay.protocol.media.EndListDuration;
+import com.github.serezhka.airplay.protocol.media.VideoStreamInfo;
+import com.github.serezhka.airplay.server.Playback;
import com.sun.jna.Native;
import lombok.extern.slf4j.Slf4j;
-import org.freedesktop.gstreamer.*;
+import org.freedesktop.gstreamer.Buffer;
+import org.freedesktop.gstreamer.BusSyncReply;
+import org.freedesktop.gstreamer.Caps;
+import org.freedesktop.gstreamer.Element;
+import org.freedesktop.gstreamer.Format;
+import org.freedesktop.gstreamer.Gst;
+import org.freedesktop.gstreamer.Pipeline;
+import org.freedesktop.gstreamer.Version;
import org.freedesktop.gstreamer.elements.AppSink;
import org.freedesktop.gstreamer.elements.AppSrc;
import org.freedesktop.gstreamer.glib.GLib;
import org.freedesktop.gstreamer.interfaces.VideoOverlay;
import org.freedesktop.gstreamer.swing.GstVideoComponent;
-import javax.swing.*;
-import java.awt.*;
-import java.util.concurrent.TimeUnit;
+import javax.swing.JFrame;
+import java.awt.Canvas;
+import java.awt.Color;
@Slf4j
-public class GstPlayer implements AirPlayConsumer {
+public class GstPlayer implements Playback {
static {
GstPlayerUtils.configurePaths();
GLib.setEnv("GST_DEBUG_NO_COLOR", "1", true);
- GLib.setEnv("GST_DEBUG_FILE", AppLogs.playerLogFile("gstreamer").toString(), true);
+ GLib.setEnv("GST_DEBUG_FILE", NativeProcessLog.playerLogFile("gstreamer").toString(), true);
GLib.setEnv("GST_DEBUG", System.getProperty("airplay.gst.debug", "3"), true);
Gst.init(Version.of(1, 10), "BasicPipeline");
}
@@ -37,17 +44,15 @@ public class GstPlayer implements AirPlayConsumer {
private final Runnable attachWindow;
private final boolean nativeFullscreen;
private final boolean useD3d11;
- private final boolean useXimage;
private final AppSrc h264Src;
private final AppSrc alacSrc;
private final AppSrc aacEldSrc;
private final AppSrc aacLcSrc;
- private Pipeline hlsPipeline;
- private String hlsUri;
- private JFrame hlsWindow;
- private Canvas hlsCanvas;
+ private final GstHlsPipeline hls = new GstHlsPipeline();
+ private volatile Playback.Observer observer = Playback.Observer.NONE;
+ private volatile double volumeLinear = 1.0;
private AudioStreamInfo.CompressionType audioCompressionType;
@@ -56,21 +61,20 @@ public GstPlayer() {
}
public GstPlayer(int fps) {
- log.info("GStreamer debug log: {}", AppLogs.playerLogFile("gstreamer"));
+ log.info("GStreamer debug log: {}", NativeProcessLog.playerLogFile("gstreamer"));
+ hls.setOnEnded(() -> observer.onEnded());
int framerate = Math.max(1, fps);
- useD3d11 = Registry.get().lookupFeature("d3d11videosink") != null;
- useXimage = Registry.get().lookupFeature("ximagesink") != null;
+ useD3d11 = GstVideoSinkFactory.hasD3d11();
+ boolean useXimage = GstVideoSinkFactory.hasXimage();
boolean forceAppsink = Boolean.parseBoolean(System.getProperty("airplay.gst.appsink", "false"))
|| Boolean.parseBoolean(System.getenv().getOrDefault("AIRPLAY_GST_APPSINK", "false"));
nativeFullscreen = useD3d11 && !forceAppsink;
String sinkLaunch;
if (forceAppsink) {
- // fakesink is more stable than appsink+Swing for long CI benches under xvfb.
sinkLaunch = " ! fakesink name=sink sync=false async=false";
} else if (useD3d11) {
sinkLaunch = " ! d3d11upload ! d3d11convert"
- + " ! d3d11videosink name=sink sync=false force-aspect-ratio=true"
- + " fullscreen-toggle-mode=property fullscreen=true";
+ + " ! d3d11videosink name=sink sync=false force-aspect-ratio=true";
} else if (useXimage) {
sinkLaunch = " ! videoscale add-borders=true ! video/x-raw,format=BGRx"
+ " ! ximagesink name=sink sync=false force-aspect-ratio=true";
@@ -86,7 +90,6 @@ public GstPlayer(int fps) {
h264Src = (AppSrc) h264Pipeline.getElementByName("h264-src");
h264Src.setStreamType(AppSrc.StreamType.STREAM);
- // AirPlay sends Annex-B NAL units (start codes), not length-prefixed AU.
h264Src.setCaps(Caps.fromString(
"video/x-h264,colorimetry=bt709,stream-format=(string)byte-stream,alignment=(string)nal,framerate="
+ framerate + "/1"));
@@ -95,8 +98,9 @@ public GstPlayer(int fps) {
h264Src.set("do-timestamp", true);
h264Src.set("emit-signals", true);
- alacPipeline = (Pipeline) Gst.parseLaunch("appsrc name=alac-src ! avdec_alac ! audioconvert ! audioresample ! autoaudiosink sync=false");
-
+ alacPipeline = (Pipeline) Gst.parseLaunch(
+ "appsrc name=alac-src ! avdec_alac ! audioconvert ! audioresample"
+ + " ! volume name=alac-vol ! autoaudiosink sync=false");
alacSrc = (AppSrc) alacPipeline.getElementByName("alac-src");
alacSrc.setStreamType(AppSrc.StreamType.STREAM);
alacSrc.setCaps(Caps.fromString("audio/x-alac,mpegversion=(int)4,channels=(int)2,rate=(int)44100,stream-format=raw,codec_data=(buffer)00000024616c616300000000000001600010280a0e0200ff00000000000000000000ac44"));
@@ -104,8 +108,9 @@ public GstPlayer(int fps) {
alacSrc.set("format", Format.TIME);
alacSrc.set("emit-signals", true);
- aacEldPipeline = (Pipeline) Gst.parseLaunch("appsrc name=aac-eld-src ! avdec_aac ! audioconvert ! audioresample ! autoaudiosink sync=false");
-
+ aacEldPipeline = (Pipeline) Gst.parseLaunch(
+ "appsrc name=aac-eld-src ! avdec_aac ! audioconvert ! audioresample"
+ + " ! volume name=aac-eld-vol ! autoaudiosink sync=false");
aacEldSrc = (AppSrc) aacEldPipeline.getElementByName("aac-eld-src");
aacEldSrc.setStreamType(AppSrc.StreamType.STREAM);
aacEldSrc.setCaps(Caps.fromString("audio/mpeg,mpegversion=(int)4,channels=(int)2,rate=(int)44100,stream-format=raw,codec_data=(buffer)f8e85000"));
@@ -113,9 +118,9 @@ public GstPlayer(int fps) {
aacEldSrc.set("format", Format.TIME);
aacEldSrc.set("emit-signals", true);
- // AAC-LC (ct=4): accept ADTS (test client) or raw AUs with LC ASC.
aacLcPipeline = (Pipeline) Gst.parseLaunch(
- "appsrc name=aac-lc-src ! aacparse ! avdec_aac ! audioconvert ! audioresample ! autoaudiosink sync=false");
+ "appsrc name=aac-lc-src ! aacparse ! avdec_aac ! audioconvert ! audioresample"
+ + " ! volume name=aac-lc-vol ! autoaudiosink sync=false");
aacLcSrc = (AppSrc) aacLcPipeline.getElementByName("aac-lc-src");
aacLcSrc.setStreamType(AppSrc.StreamType.STREAM);
aacLcSrc.setCaps(Caps.fromString("audio/mpeg,mpegversion=(int)4,stream-format=adts,channels=(int)2,rate=(int)44100"));
@@ -125,15 +130,10 @@ public GstPlayer(int fps) {
Element sink = h264Pipeline.getElementByName("sink");
if (forceAppsink) {
- // Headless bench path: no VideoOverlay / Swing.
- window = null;
- attachWindow = () -> {
- };
- } else if (useD3d11) {
window = null;
attachWindow = () -> {
};
- } else if (useXimage) {
+ } else if (useD3d11 || useXimage) {
Canvas canvas = new Canvas();
canvas.setBackground(Color.BLACK);
window = GstFullscreenWindow.create(canvas);
@@ -157,6 +157,8 @@ public GstPlayer(int fps) {
@Override
public void onVideoFormat(VideoStreamInfo videoStreamInfo) {
+ // Release HLS before starting the appsrc mirror pipeline (shared GPU/display).
+ hls.stop();
if (window != null) {
GstFullscreenWindow.show(window);
GstFullscreenWindow.onEdt(attachWindow);
@@ -216,101 +218,81 @@ public void onAudioSrcDisconnect() {
}
@Override
- public void onMediaPlaylist(String playlistUri) {
- startHlsPipeline(playlistUri);
+ public void setObserver(Playback.Observer observer) {
+ this.observer = observer == null ? Playback.Observer.NONE : observer;
}
- private void startHlsPipeline(String playlistUri) {
- stopHlsPipeline();
- hlsUri = playlistUri;
- Element videoSink = createHlsVideoSink();
- hlsPipeline = (Pipeline) Gst.parseLaunch("playbin3 name=hls");
- hlsPipeline.set("uri", playlistUri);
- hlsPipeline.set("video-sink", videoSink);
- if (useXimage && hlsCanvas != null) {
- GstFullscreenWindow.show(hlsWindow);
- VideoOverlay overlay = VideoOverlay.wrap(videoSink);
- Runnable attachHlsWindow = () -> overlay.setWindowHandle(Native.getComponentID(hlsCanvas));
- hlsPipeline.getBus().setSyncHandler(message -> {
- if (!VideoOverlay.isPrepareWindowHandleMessage(message)) {
- return BusSyncReply.PASS;
- }
- GstFullscreenWindow.onEdt(attachHlsWindow);
- return BusSyncReply.DROP;
- });
- }
- hlsPipeline.getBus().connect((Bus.EOS) source -> {
- if (hlsUri == null) {
- return;
- }
- log.info("HLS ended, requesting playlist refresh for {}", hlsUri);
- HlsLifecycle.notifyEnded();
- });
- hlsPipeline.play();
+ @Override
+ public void onPlaylist(String playlistUri) {
+ hls.start(playlistUri, volumeLinear);
}
- private Element createHlsVideoSink() {
- if (useD3d11) {
- Element sink = ElementFactory.make("d3d11videosink", "hls-sink");
- sink.set("sync", false);
- sink.set("force-aspect-ratio", true);
- sink.set("fullscreen", true);
- return sink;
- }
- if (useXimage) {
- hlsCanvas = new Canvas();
- hlsCanvas.setBackground(Color.BLACK);
- hlsWindow = GstFullscreenWindow.create(hlsCanvas);
- Element sink = ElementFactory.make("ximagesink", "hls-sink");
- sink.set("sync", false);
- sink.set("force-aspect-ratio", true);
- return sink;
- }
- Element sink = ElementFactory.make("autovideosink", "hls-sink");
- sink.set("sync", false);
- return sink;
+ @Override
+ public void onPlaylistRemoved() {
+ hls.stop();
}
- private void stopHlsPipeline() {
- if (hlsPipeline != null) {
- hlsPipeline.stop();
- hlsPipeline = null;
+ @Override
+ public void onPlaylistContent(String playlistUri, String content) {
+ if (playlistUri == null || !playlistUri.contains("mediadata.m3u8") || content == null) {
+ return;
}
- if (hlsWindow != null) {
- GstFullscreenWindow.hide(hlsWindow);
- hlsWindow = null;
- hlsCanvas = null;
+ double sum = EndListDuration.sumSeconds(content);
+ if (sum > 0) {
+ hls.noteMediaDuration(sum);
}
}
@Override
- public void onMediaPlaylistRemove() {
- hlsUri = null;
- stopHlsPipeline();
+ public void onPause() {
+ hls.pause();
}
@Override
- public void onMediaPlaylistPause() {
- if (hlsPipeline != null && hlsPipeline.isPlaying()) {
- hlsPipeline.pause();
- }
+ public void onResume() {
+ hls.resume();
+ }
+
+ @Override
+ public void onSeek(double positionSeconds) {
+ hls.seek(positionSeconds);
}
@Override
- public void onMediaPlaylistResume() {
- if (hlsPipeline != null && !hlsPipeline.isPlaying()) {
- hlsPipeline.play();
+ public void onVolume(double volumeLinear) {
+ this.volumeLinear = Math.max(0.0, Math.min(1.0, volumeLinear));
+ applyVolume(alacPipeline, "alac-vol");
+ applyVolume(aacEldPipeline, "aac-eld-vol");
+ applyVolume(aacLcPipeline, "aac-lc-vol");
+ hls.setVolume(this.volumeLinear);
+ log.info("Volume set to {}", this.volumeLinear);
+ }
+
+ private void applyVolume(Pipeline pipeline, String elementName) {
+ Element vol = pipeline.getElementByName(elementName);
+ if (vol != null) {
+ vol.set("volume", volumeLinear);
}
}
@Override
- public PlaybackInfo playbackInfo() {
- if (hlsPipeline != null) {
- return new PlaybackInfo(
- hlsPipeline.queryDuration(TimeUnit.SECONDS),
- hlsPipeline.queryPosition(TimeUnit.SECONDS));
+ public double volume() {
+ return volumeLinear;
+ }
+
+ @Override
+ public Playback.Info info() {
+ if (!hls.isActive()) {
+ return Playback.super.info();
+ }
+ double duration = hls.durationSeconds();
+ double position = hls.currentPositionSeconds();
+ if (duration > 0) {
+ position = Math.min(position, duration);
}
- return AirPlayConsumer.super.playbackInfo();
+ // VOD EOS is paused locally; report rate=1 (rate=0 looks like user pause).
+ double rate = (hls.isPaused() && !hls.isEnded()) ? 0 : 1;
+ return new Playback.Info(duration, position, rate);
}
boolean isVideoPipelinePlaying() {
diff --git a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstPlayerUtils.java b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstPlayerUtils.java
index ae96e90c..e67a1382 100644
--- a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstPlayerUtils.java
+++ b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstPlayerUtils.java
@@ -69,6 +69,14 @@ static void configurePaths() {
Kernel32.INSTANCE.SetEnvironmentVariable("PATH", gstPath
+ File.pathSeparator + systemPath);
}
+ File binDir = new File(gstPath);
+ File rootDir = binDir.getName().equalsIgnoreCase("bin") ? binDir.getParentFile() : binDir;
+ if (rootDir != null) {
+ File plugins = new File(rootDir, "lib\\gstreamer-1.0");
+ if (plugins.isDirectory()) {
+ Kernel32.INSTANCE.SetEnvironmentVariable("GST_PLUGIN_PATH", plugins.getAbsolutePath());
+ }
+ }
}
} else {
StringBuilder jnaPath = new StringBuilder(System.getProperty("jna.library.path", "").trim());
diff --git a/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstVideoSinkFactory.java b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstVideoSinkFactory.java
new file mode 100644
index 00000000..ac86877e
--- /dev/null
+++ b/player/gstreamer/src/main/java/com/github/serezhka/airplay/player/gstreamer/GstVideoSinkFactory.java
@@ -0,0 +1,103 @@
+package com.github.serezhka.airplay.player.gstreamer;
+
+import lombok.extern.slf4j.Slf4j;
+import org.freedesktop.gstreamer.Bin;
+import org.freedesktop.gstreamer.Element;
+import org.freedesktop.gstreamer.ElementFactory;
+import org.freedesktop.gstreamer.GhostPad;
+import org.freedesktop.gstreamer.Pad;
+import org.freedesktop.gstreamer.Registry;
+
+import java.awt.Canvas;
+import java.awt.Color;
+
+/**
+ * Shared fullscreen video sinks for mirroring and HLS (d3d11 / ximage / auto).
+ */
+@Slf4j
+final class GstVideoSinkFactory {
+
+ record Result(Element sink, Canvas canvas, boolean overlay) {
+ }
+
+ static boolean hasD3d11() {
+ return Registry.get().lookupFeature("d3d11videosink") != null;
+ }
+
+ static boolean hasXimage() {
+ return Registry.get().lookupFeature("ximagesink") != null;
+ }
+
+ /**
+ * @param sync clock-sync to the pipeline (prefer true when audio+video share one pipeline)
+ */
+ static Result create(String namePrefix, boolean sync) {
+ if (hasD3d11()) {
+ Canvas canvas = newCanvas();
+ Element sink = createD3d11Bin(namePrefix, sync);
+ return new Result(sink, canvas, true);
+ }
+ if (hasXimage()) {
+ Canvas canvas = newCanvas();
+ Element sink = ElementFactory.make("ximagesink", namePrefix + "-sink");
+ applySinkProps(sink, sync);
+ return new Result(sink, canvas, true);
+ }
+ Element sink = ElementFactory.make("autovideosink", namePrefix + "-sink");
+ applySinkProps(sink, sync);
+ return new Result(sink, null, false);
+ }
+
+ /** Leaf sink used for VideoOverlay (inside a bin when d3d11). */
+ static Element overlayTarget(Element sinkOrBin, String leafName) {
+ if (sinkOrBin instanceof Bin bin) {
+ Element inner = bin.getElementByName(leafName);
+ return inner != null ? inner : sinkOrBin;
+ }
+ return sinkOrBin;
+ }
+
+ private static Element createD3d11Bin(String namePrefix, boolean sync) {
+ Bin bin = new Bin(namePrefix + "-bin");
+ Element upload = ElementFactory.make("d3d11upload", namePrefix + "-upload");
+ Element convert = ElementFactory.make("d3d11convert", namePrefix + "-convert");
+ Element sink = ElementFactory.make("d3d11videosink", namePrefix + "-sink");
+ if (upload == null || convert == null || sink == null) {
+ Element fallback = ElementFactory.make("autovideosink", namePrefix + "-sink");
+ applySinkProps(fallback, sync);
+ return fallback;
+ }
+ applySinkProps(sink, sync);
+ bin.addMany(upload, convert, sink);
+ if (!Element.linkMany(upload, convert, sink)) {
+ log.warn("Failed to link d3d11 sink chain, falling back to autovideosink");
+ Element fallback = ElementFactory.make("autovideosink", namePrefix + "-sink");
+ applySinkProps(fallback, sync);
+ return fallback;
+ }
+ Pad sinkPad = upload.getStaticPad("sink");
+ bin.addPad(new GhostPad("sink", sinkPad));
+ return bin;
+ }
+
+ private static void applySinkProps(Element sink, boolean sync) {
+ if (sink == null) {
+ return;
+ }
+ sink.set("sync", sync);
+ try {
+ sink.set("force-aspect-ratio", true);
+ } catch (Exception ignored) {
+ // not all sinks support it
+ }
+ }
+
+ private static Canvas newCanvas() {
+ Canvas canvas = new Canvas();
+ canvas.setBackground(Color.BLACK);
+ return canvas;
+ }
+
+ private GstVideoSinkFactory() {
+ }
+}
diff --git a/player/gstreamer/src/test/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipelineSmokeTest.java b/player/gstreamer/src/test/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipelineSmokeTest.java
new file mode 100644
index 00000000..ca357b79
--- /dev/null
+++ b/player/gstreamer/src/test/java/com/github/serezhka/airplay/player/gstreamer/GstHlsPipelineSmokeTest.java
@@ -0,0 +1,158 @@
+package com.github.serezhka.airplay.player.gstreamer;
+
+import org.freedesktop.gstreamer.Gst;
+import org.freedesktop.gstreamer.Version;
+import org.freedesktop.gstreamer.glib.GLib;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Partial self-test for HLS pause/resume/seek without a phone.
+ * Needs GStreamer on PATH (or configured via {@link GstPlayerUtils}).
+ */
+class GstHlsPipelineSmokeTest {
+
+ @TempDir
+ Path temp;
+
+ @BeforeAll
+ static void initGst() {
+ Assumptions.assumeTrue(gstLaunchAvailable(), "gst-launch-1.0 not available");
+ GstPlayerUtils.configurePaths();
+ GLib.setEnv("GST_DEBUG_NO_COLOR", "1", true);
+ GLib.setEnv("GST_DEBUG", "1", true);
+ if (!Gst.isInitialized()) {
+ Gst.init(Version.of(1, 10), "GstHlsPipelineSmokeTest");
+ }
+ }
+
+ @Test
+ @Timeout(90)
+ void pauseResumeSeekAdvancesPipelineClock() throws Exception {
+ Assumptions.assumeTrue(gstLaunchAvailable(), "gst-launch-1.0 not available");
+
+ Path media = temp.resolve("smoke.ts");
+ generateSmokeTs(media);
+ Assumptions.assumeTrue(Files.size(media) > 1000, "failed to generate smoke.ts");
+
+ System.setProperty("airplay.gst.hls.headless", "true");
+ String uri = media.toUri().toString();
+
+ GstHlsPipeline hls = new GstHlsPipeline();
+ try {
+ hls.start(uri, 1.0);
+ awaitPosition(hls, 0.4, 8_000);
+
+ double beforePause = hls.pipelinePositionSeconds();
+ hls.pause();
+ Thread.sleep(800);
+ double duringPause = hls.pipelinePositionSeconds();
+ assertTrue(Math.abs(duringPause - beforePause) < 0.35,
+ "position should stay put while paused: " + beforePause + " -> " + duringPause);
+
+ hls.resume();
+ awaitAdvance(hls, duringPause + 0.3, 8_000);
+
+ double beforeSeek = hls.pipelinePositionSeconds();
+ double target = Math.max(0.5, beforeSeek + 1.5);
+ hls.seek(target);
+ awaitNear(hls, target, 1.5, 10_000);
+ awaitAdvance(hls, target + 0.2, 8_000);
+ } finally {
+ hls.stop();
+ System.clearProperty("airplay.gst.hls.headless");
+ }
+ }
+
+ private static boolean gstLaunchAvailable() {
+ try {
+ GstPlayerUtils.configurePaths();
+ Process p = new ProcessBuilder(gstLaunchCommand(), "--version")
+ .redirectErrorStream(true)
+ .start();
+ boolean finished = p.waitFor(5, TimeUnit.SECONDS);
+ return finished && p.exitValue() == 0;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private static String gstLaunchCommand() {
+ String fromEnv = System.getenv("GSTREAMER_1_0_ROOT_MSVC_X86_64");
+ if (fromEnv != null && !fromEnv.isBlank()) {
+ Path candidate = Path.of(fromEnv, "bin", "gst-launch-1.0.exe");
+ if (Files.isRegularFile(candidate)) {
+ return candidate.toString();
+ }
+ }
+ return "gst-launch-1.0";
+ }
+
+ private static void generateSmokeTs(Path out) throws Exception {
+ String location = out.toAbsolutePath().toString().replace('\\', '/');
+ List cmd = new ArrayList<>();
+ cmd.add(gstLaunchCommand());
+ cmd.add("-e");
+ cmd.add("videotestsrc");
+ cmd.add("num-buffers=180");
+ cmd.add("is-live=false");
+ cmd.add("!");
+ cmd.add("video/x-raw,framerate=30/1,width=320,height=240");
+ cmd.add("!");
+ cmd.add("videoconvert");
+ cmd.add("!");
+ cmd.add("x264enc");
+ cmd.add("tune=zerolatency");
+ cmd.add("!");
+ cmd.add("h264parse");
+ cmd.add("!");
+ cmd.add("mpegtsmux");
+ cmd.add("!");
+ cmd.add("filesink");
+ cmd.add("location=" + location);
+ Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start();
+ String output = new String(p.getInputStream().readAllBytes());
+ boolean finished = p.waitFor(45, TimeUnit.SECONDS);
+ Assumptions.assumeTrue(finished && p.exitValue() == 0,
+ "gst-launch failed generating smoke.ts: " + output);
+ }
+
+ private static void awaitPosition(GstHlsPipeline hls, double min, long timeoutMs) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + timeoutMs;
+ while (System.currentTimeMillis() < deadline) {
+ if (hls.pipelinePositionSeconds() >= min) {
+ return;
+ }
+ Thread.sleep(100);
+ }
+ assertTrue(false, "position never reached " + min + ", last=" + hls.pipelinePositionSeconds());
+ }
+
+ private static void awaitAdvance(GstHlsPipeline hls, double min, long timeoutMs) throws InterruptedException {
+ awaitPosition(hls, min, timeoutMs);
+ }
+
+ private static void awaitNear(GstHlsPipeline hls, double target, double slack, long timeoutMs)
+ throws InterruptedException {
+ long deadline = System.currentTimeMillis() + timeoutMs;
+ while (System.currentTimeMillis() < deadline) {
+ double pos = hls.pipelinePositionSeconds();
+ if (Math.abs(pos - target) <= slack || pos >= target - 0.1) {
+ return;
+ }
+ Thread.sleep(100);
+ }
+ assertTrue(false, "seek did not land near " + target + ", last=" + hls.pipelinePositionSeconds());
+ }
+}
diff --git a/player/gstreamer/src/test/java/com/github/serezhka/airplay/player/gstreamer/PlayerBoundaryTest.java b/player/gstreamer/src/test/java/com/github/serezhka/airplay/player/gstreamer/PlayerBoundaryTest.java
new file mode 100644
index 00000000..1ed77260
--- /dev/null
+++ b/player/gstreamer/src/test/java/com/github/serezhka/airplay/player/gstreamer/PlayerBoundaryTest.java
@@ -0,0 +1,23 @@
+package com.github.serezhka.airplay.player.gstreamer;
+
+import com.tngtech.archunit.core.importer.ImportOption;
+import com.tngtech.archunit.junit.AnalyzeClasses;
+import com.tngtech.archunit.junit.ArchTest;
+import com.tngtech.archunit.lang.ArchRule;
+
+import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
+
+@AnalyzeClasses(
+ packages = "com.github.serezhka.airplay.player.gstreamer",
+ importOptions = ImportOption.DoNotIncludeTests.class
+)
+class PlayerBoundaryTest {
+
+ @ArchTest
+ static final ArchRule doesNotReachIntoServerOrOtherPlayers = noClasses()
+ .should().dependOnClassesThat().resideInAnyPackage(
+ "com.github.serezhka.airplay.server.internal..",
+ "com.github.serezhka.airplay.player.ffmpeg..",
+ "com.github.serezhka.airplay.player.dump.."
+ );
+}
diff --git a/player/harness/build.gradle.kts b/player/harness/build.gradle.kts
index deecf498..bf658d05 100644
--- a/player/harness/build.gradle.kts
+++ b/player/harness/build.gradle.kts
@@ -22,16 +22,12 @@ dependencies {
implementation(libs.slf4j.api)
implementation(libs.dd.plist)
- "integrationTestImplementation"(projects.lib)
+ "integrationTestImplementation"(projects.protocol)
"integrationTestImplementation"(projects.server)
- "integrationTestImplementation"(projects.client)
"integrationTestImplementation"(projects.player.ffmpeg)
"integrationTestImplementation"(projects.player.gstreamer)
"integrationTestImplementation"(projects.player.dump)
- "integrationTestImplementation"(projects.player.vlc)
"integrationTestImplementation"(libs.bundles.logging)
- "integrationTestImplementation"(libs.eddsa)
- "integrationTestImplementation"(libs.curve25519)
"integrationTestImplementation"(libs.netty.all)
}
@@ -54,7 +50,6 @@ fun registerIntegrationTest(taskName: String, tag: String?, taskDescription: Str
"airplay.harness.reportDir",
"airplay.gst.appsink",
"airplay.gst.cli",
- "airplay.vlc.headless",
"gstreamer.path",
"jna.library.path"
).forEach { key ->
@@ -65,7 +60,6 @@ fun registerIntegrationTest(taskName: String, tag: String?, taskDescription: Str
}
// Prefer env from CI when -D was not set on the Gradle JVM.
System.getenv("AIRPLAY_GST_CLI")?.let { systemProperty("airplay.gst.cli", it) }
- System.getenv("AIRPLAY_VLC_HEADLESS")?.let { systemProperty("airplay.vlc.headless", it) }
// Defaults for bench when not overridden
if (tag == "bench") {
// Never run benches in parallel on one machine (local or single runner).
@@ -86,14 +80,6 @@ fun registerIntegrationTest(taskName: String, tag: String?, taskDescription: Str
val seconds = System.getProperty("airplay.harness.bench.seconds", "300").toLongOrNull() ?: 300L
timeout.set(Duration.ofSeconds(seconds + 120))
}
- if (tag == "vlc") {
- // Hard cap so a stuck libvlc/Swing path cannot burn the whole job.
- timeout.set(Duration.ofMinutes(2))
- systemProperty(
- "airplay.vlc.headless",
- System.getProperty("airplay.vlc.headless", "true")
- )
- }
if (tag == "ffmpeg" || tag == "gstreamer" || tag == "dump" || tag == "loopback") {
timeout.set(Duration.ofMinutes(3))
}
@@ -121,11 +107,6 @@ registerIntegrationTest(
"dump",
"Runs the dump sidecar recording smoke test."
)
-registerIntegrationTest(
- "vlcIntegrationTest",
- "vlc",
- "Runs the VLC playback smoke test."
-)
registerIntegrationTest(
"loopbackIntegrationTest",
"loopback",
diff --git a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/dump/DumpPlaybackTest.java b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/dump/DumpPlaybackTest.java
index f07ed4b3..53f94c37 100644
--- a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/dump/DumpPlaybackTest.java
+++ b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/dump/DumpPlaybackTest.java
@@ -1,9 +1,9 @@
package com.github.serezhka.airplay.player.dump;
-import com.github.serezhka.airplay.lib.AudioStreamInfo;
-import com.github.serezhka.airplay.lib.VideoStreamInfo;
+import com.github.serezhka.airplay.protocol.media.AudioStreamInfo;
+import com.github.serezhka.airplay.protocol.media.VideoStreamInfo;
import com.github.serezhka.airplay.player.test.PlaybackFixture;
-import com.github.serezhka.airplay.server.AirPlayConsumer;
+import com.github.serezhka.airplay.server.Playback;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -35,7 +35,7 @@ void recordsSyntheticAirPlayStreamBesideAPlayer(@TempDir Path tempDir) throws Ex
assertArrayEquals(PlaybackFixture.h264(), Files.readAllBytes(dumped));
}
- private static final class NoopPlayer implements AirPlayConsumer {
+ private static final class NoopPlayer implements Playback {
@Override
public void onVideoFormat(VideoStreamInfo videoStreamInfo) {
}
diff --git a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/ffmpeg/FFmpegPlaybackTest.java b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/ffmpeg/FFmpegPlaybackTest.java
index d73021d9..d69b15ef 100644
--- a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/ffmpeg/FFmpegPlaybackTest.java
+++ b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/ffmpeg/FFmpegPlaybackTest.java
@@ -1,6 +1,6 @@
package com.github.serezhka.airplay.player.ffmpeg;
-import com.github.serezhka.airplay.lib.VideoStreamInfo;
+import com.github.serezhka.airplay.protocol.media.VideoStreamInfo;
import com.github.serezhka.airplay.player.test.PlaybackFixture;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
diff --git a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/gstreamer/GStreamerPlaybackTest.java b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/gstreamer/GStreamerPlaybackTest.java
index a3412e32..1a1ea32b 100644
--- a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/gstreamer/GStreamerPlaybackTest.java
+++ b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/gstreamer/GStreamerPlaybackTest.java
@@ -1,6 +1,6 @@
package com.github.serezhka.airplay.player.gstreamer;
-import com.github.serezhka.airplay.lib.VideoStreamInfo;
+import com.github.serezhka.airplay.protocol.media.VideoStreamInfo;
import com.github.serezhka.airplay.player.harness.GstLaunchPlayer;
import com.github.serezhka.airplay.player.test.PlaybackFixture;
import org.junit.jupiter.api.Tag;
diff --git a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/BenchPlaybackTest.java b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/BenchPlaybackTest.java
index f81da190..c9beb2a4 100644
--- a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/BenchPlaybackTest.java
+++ b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/BenchPlaybackTest.java
@@ -1,11 +1,10 @@
package com.github.serezhka.airplay.player.harness;
-import com.github.serezhka.airplay.lib.VideoStreamInfo;
+import com.github.serezhka.airplay.protocol.media.VideoStreamInfo;
import com.github.serezhka.airplay.player.ffmpeg.FFmpegPlayer;
import com.github.serezhka.airplay.player.gstreamer.GstPlayer;
import com.github.serezhka.airplay.player.test.PlaybackFixture;
-import com.github.serezhka.airplay.player.vlc.VlcPlayer;
-import com.github.serezhka.airplay.server.AirPlayConsumer;
+import com.github.serezhka.airplay.server.Playback;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
@@ -34,7 +33,7 @@ void fiveMinutePlayerBench() throws Exception {
String scenario = "bench-" + player + "-" + seconds + "s";
try (PlaybackMetrics metrics = PlaybackMetrics.start(scenario, player)) {
- AirPlayConsumer raw = createPlayer(player);
+ Playback raw = createPlayer(player);
InstrumentedConsumer consumer = new InstrumentedConsumer(raw, metrics);
try {
consumer.onVideoFormat(new VideoStreamInfo("bench-" + player));
@@ -57,7 +56,7 @@ void fiveMinutePlayerBench() throws Exception {
}
}
- private static AirPlayConsumer createPlayer(String player) {
+ private static Playback createPlayer(String player) {
return switch (player) {
case "ffmpeg" -> {
assumeTrue(BenchPlaybackTest.onPath("ffplay"), "ffplay not on PATH");
@@ -72,17 +71,13 @@ private static AirPlayConsumer createPlayer(String player) {
}
yield new GstPlayer();
}
- case "vlc" -> {
- assumeTrue(vlcLikelyAvailable(), "VLC native libraries not available");
- yield new VlcPlayer();
- }
case "recording", "none" -> new RecordingConsumer();
default -> throw new IllegalArgumentException("Unknown bench player: " + player
- + " (use ffmpeg|gstreamer|vlc|recording)");
+ + " (use ffmpeg|gstreamer|recording)");
};
}
- private static void feedFor(AirPlayConsumer consumer, int seconds) throws InterruptedException {
+ private static void feedFor(Playback consumer, int seconds) throws InterruptedException {
byte[] frame = PlaybackFixture.h264();
long frameDelayMs = 33L;
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
@@ -146,13 +141,4 @@ static boolean gstreamerLikelyAvailable() {
}
return onPath("gst-launch-1.0");
}
-
- static boolean vlcLikelyAvailable() {
- return Files.isRegularFile(Path.of("/Applications/VLC.app/Contents/MacOS/lib/libvlc.dylib"))
- || Files.isRegularFile(Path.of("/usr/lib/x86_64-linux-gnu/libvlc.so.5"))
- || Files.isRegularFile(Path.of("/usr/lib/aarch64-linux-gnu/libvlc.so.5"))
- || Files.isRegularFile(Path.of("/usr/lib/libvlc.so.5"))
- || onPath("vlc")
- || onPath("cvlc");
- }
}
diff --git a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/GstLaunchPlayer.java b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/GstLaunchPlayer.java
index f8129b5b..2f7ccaf8 100644
--- a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/GstLaunchPlayer.java
+++ b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/GstLaunchPlayer.java
@@ -1,8 +1,8 @@
package com.github.serezhka.airplay.player.harness;
-import com.github.serezhka.airplay.lib.AudioStreamInfo;
-import com.github.serezhka.airplay.lib.VideoStreamInfo;
-import com.github.serezhka.airplay.server.AirPlayConsumer;
+import com.github.serezhka.airplay.protocol.media.AudioStreamInfo;
+import com.github.serezhka.airplay.protocol.media.VideoStreamInfo;
+import com.github.serezhka.airplay.server.Playback;
import java.io.IOException;
import java.io.InputStream;
@@ -16,7 +16,7 @@
* Parses only (no decoder) so runners need just tools + base plugins — avoids JNI SIGSEGV
* and missing avdec_* plugin issues on minimal images.
*/
-public final class GstLaunchPlayer implements AirPlayConsumer {
+public final class GstLaunchPlayer implements Playback {
private final AtomicBoolean closed = new AtomicBoolean();
private final AtomicBoolean started = new AtomicBoolean();
diff --git a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/InstrumentedConsumer.java b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/InstrumentedConsumer.java
index 3b4d6dcf..58abcef3 100644
--- a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/InstrumentedConsumer.java
+++ b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/InstrumentedConsumer.java
@@ -1,19 +1,19 @@
package com.github.serezhka.airplay.player.harness;
-import com.github.serezhka.airplay.lib.AudioStreamInfo;
-import com.github.serezhka.airplay.lib.VideoStreamInfo;
-import com.github.serezhka.airplay.server.AirPlayConsumer;
+import com.github.serezhka.airplay.protocol.media.AudioStreamInfo;
+import com.github.serezhka.airplay.protocol.media.VideoStreamInfo;
+import com.github.serezhka.airplay.server.Playback;
/**
* Decorator that times {@link #onVideo} / {@link #onAudio} and feeds {@link PlaybackMetrics}.
* Keeps instrumentation out of production player classes.
*/
-public final class InstrumentedConsumer implements AirPlayConsumer {
+public final class InstrumentedConsumer implements Playback {
- private final AirPlayConsumer delegate;
+ private final Playback delegate;
private final PlaybackMetrics metrics;
- public InstrumentedConsumer(AirPlayConsumer delegate, PlaybackMetrics metrics) {
+ public InstrumentedConsumer(Playback delegate, PlaybackMetrics metrics) {
this.delegate = delegate;
this.metrics = metrics;
}
@@ -56,26 +56,41 @@ public void onAudioSrcDisconnect() {
}
@Override
- public void onMediaPlaylist(String playlistUri) {
- delegate.onMediaPlaylist(playlistUri);
+ public void onPlaylist(String playlistUri) {
+ delegate.onPlaylist(playlistUri);
}
@Override
- public void onMediaPlaylistRemove() {
- delegate.onMediaPlaylistRemove();
+ public void onPlaylistRemoved() {
+ delegate.onPlaylistRemoved();
}
@Override
- public void onMediaPlaylistPause() {
- delegate.onMediaPlaylistPause();
+ public void onPause() {
+ delegate.onPause();
}
@Override
- public void onMediaPlaylistResume() {
- delegate.onMediaPlaylistResume();
+ public void onResume() {
+ delegate.onResume();
}
- public AirPlayConsumer delegate() {
+ @Override
+ public void onSeek(double positionSeconds) {
+ delegate.onSeek(positionSeconds);
+ }
+
+ @Override
+ public void onVolume(double volumeLinear) {
+ delegate.onVolume(volumeLinear);
+ }
+
+ @Override
+ public double volume() {
+ return delegate.volume();
+ }
+
+ public Playback delegate() {
return delegate;
}
}
diff --git a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/LoopbackControlTest.java b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/LoopbackControlTest.java
deleted file mode 100644
index f25601cd..00000000
--- a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/LoopbackControlTest.java
+++ /dev/null
@@ -1,65 +0,0 @@
-package com.github.serezhka.airplay.player.harness;
-
-import com.dd.plist.NSDictionary;
-import com.github.serezhka.airplay.client.control.ControlClient;
-import com.github.serezhka.airplay.server.AirPlayConfig;
-import com.github.serezhka.airplay.server.internal.ControlServer;
-import net.i2p.crypto.eddsa.EdDSAPublicKey;
-import net.i2p.crypto.eddsa.KeyPairGenerator;
-import org.junit.jupiter.api.Tag;
-import org.junit.jupiter.api.Test;
-import org.whispersystems.curve25519.Curve25519;
-import org.whispersystems.curve25519.Curve25519KeyPair;
-
-import java.nio.charset.StandardCharsets;
-import java.security.KeyPair;
-import java.security.MessageDigest;
-import java.util.Arrays;
-
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-@Tag("loopback")
-class LoopbackControlTest {
-
- @Test
- void infoAndLegacyPairingAgainstLocalControlServer() throws Exception {
- AirPlayConfig config = new AirPlayConfig();
- config.setServerName("harness-loopback");
- config.setWidth(1280);
- config.setHeight(720);
- config.setFps(30);
-
- RecordingConsumer consumer = new RecordingConsumer();
- ControlServer controlServer = new ControlServer(config, consumer);
- controlServer.start();
- try {
- ControlClient client = new ControlClient("127.0.0.1", controlServer.getPort());
- NSDictionary info = client.requestInfo();
- assertNotNull(info.get("features"));
- assertNotNull(info.get("name"));
-
- byte[] pairSetup = client.pairSetup();
- assertTrue(pairSetup.length >= 32, "pair-setup should return Ed25519 public key");
-
- KeyPair keyPair = new KeyPairGenerator().generateKeyPair();
- Curve25519 curve25519 = Curve25519.getInstance(Curve25519.BEST);
- Curve25519KeyPair curve25519KeyPair = curve25519.generateKeyPair();
- byte[] pairVerify1Request = new byte[68];
- pairVerify1Request[0] = 1;
- System.arraycopy(curve25519KeyPair.getPublicKey(), 0, pairVerify1Request, 4, 32);
- System.arraycopy(((EdDSAPublicKey) keyPair.getPublic()).getAbyte(), 0, pairVerify1Request, 36, 32);
- byte[] pairVerify1Response = client.pairVerify(pairVerify1Request);
- assertTrue(pairVerify1Response.length >= 96, "pair-verify M2 should be 96 bytes");
-
- byte[] atvPublicKey = Arrays.copyOfRange(pairVerify1Response, 0, 32);
- byte[] sharedSecret = curve25519.calculateAgreement(atvPublicKey, curve25519KeyPair.getPrivateKey());
- MessageDigest sha512Digest = MessageDigest.getInstance("SHA-512");
- sha512Digest.update("Pair-Verify-AES-Key".getBytes(StandardCharsets.UTF_8));
- sha512Digest.update(sharedSecret);
- assertTrue(sha512Digest.digest().length >= 16);
- } finally {
- controlServer.stop();
- }
- }
-}
diff --git a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/LoopbackMediaCallbackTest.java b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/LoopbackMediaCallbackTest.java
index db72bd7e..d4bc33ff 100644
--- a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/LoopbackMediaCallbackTest.java
+++ b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/LoopbackMediaCallbackTest.java
@@ -1,7 +1,7 @@
package com.github.serezhka.airplay.player.harness;
-import com.github.serezhka.airplay.lib.AudioStreamInfo;
-import com.github.serezhka.airplay.lib.VideoStreamInfo;
+import com.github.serezhka.airplay.protocol.media.AudioStreamInfo;
+import com.github.serezhka.airplay.protocol.media.VideoStreamInfo;
import com.github.serezhka.airplay.player.test.PlaybackFixture;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
diff --git a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/RecordingConsumer.java b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/RecordingConsumer.java
index f5ea30dc..08539e94 100644
--- a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/RecordingConsumer.java
+++ b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/harness/RecordingConsumer.java
@@ -1,14 +1,14 @@
package com.github.serezhka.airplay.player.harness;
-import com.github.serezhka.airplay.lib.AudioStreamInfo;
-import com.github.serezhka.airplay.lib.VideoStreamInfo;
-import com.github.serezhka.airplay.server.AirPlayConsumer;
+import com.github.serezhka.airplay.protocol.media.AudioStreamInfo;
+import com.github.serezhka.airplay.protocol.media.VideoStreamInfo;
+import com.github.serezhka.airplay.server.Playback;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/** Minimal consumer that records callbacks without touching media sinks. */
-public final class RecordingConsumer implements AirPlayConsumer {
+public final class RecordingConsumer implements Playback {
private final AtomicReference videoFormat = new AtomicReference<>();
private final AtomicReference audioFormat = new AtomicReference<>();
diff --git a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/test/PlaybackFixture.java b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/test/PlaybackFixture.java
index 5ea8cd59..dd4b0e49 100644
--- a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/test/PlaybackFixture.java
+++ b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/test/PlaybackFixture.java
@@ -1,6 +1,6 @@
package com.github.serezhka.airplay.player.test;
-import com.github.serezhka.airplay.server.AirPlayConsumer;
+import com.github.serezhka.airplay.server.Playback;
import java.io.IOException;
import java.io.InputStream;
@@ -19,7 +19,7 @@ public static byte[] h264() {
}
}
- public static void play(AirPlayConsumer player) throws InterruptedException {
+ public static void play(Playback player) throws InterruptedException {
byte[] video = h264();
int chunkSize = Math.max(1, (video.length + FRAME_COUNT - 1) / FRAME_COUNT);
for (int offset = 0; offset < video.length; offset += chunkSize) {
diff --git a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/vlc/VlcPlaybackTest.java b/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/vlc/VlcPlaybackTest.java
deleted file mode 100644
index 1b991b66..00000000
--- a/player/harness/src/integrationTest/java/com/github/serezhka/airplay/player/vlc/VlcPlaybackTest.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package com.github.serezhka.airplay.player.vlc;
-
-import com.github.serezhka.airplay.lib.VideoStreamInfo;
-import com.github.serezhka.airplay.player.test.PlaybackFixture;
-import org.junit.jupiter.api.Tag;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.Timeout;
-
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.concurrent.TimeUnit;
-
-import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.junit.jupiter.api.Assumptions.assumeTrue;
-
-@Tag("vlc")
-class VlcPlaybackTest {
-
- @Test
- @Timeout(value = 60, unit = TimeUnit.SECONDS)
- void playsSyntheticAirPlayStream() throws Exception {
- assumeTrue(vlcAvailable(), "VLC / cvlc not available");
- System.setProperty("airplay.vlc.headless", "true");
-
- VlcPlayer player = new VlcPlayer();
- try {
- player.onVideoFormat(new VideoStreamInfo("playback-test"));
- assertTrue(player.isCliAlive(), "cvlc/vlc did not start");
- assertDoesNotThrow(() -> PlaybackFixture.play(player));
- assertTrue(player.isCliAlive(), "cvlc/vlc exited while receiving H264");
- } finally {
- player.onVideoSrcDisconnect();
- }
- }
-
- private static boolean vlcAvailable() {
- if (Files.isRegularFile(Path.of("/Applications/VLC.app/Contents/MacOS/lib/libvlc.dylib"))
- || Files.isRegularFile(Path.of("/usr/lib/x86_64-linux-gnu/libvlc.so.5"))
- || Files.isRegularFile(Path.of("/usr/lib/aarch64-linux-gnu/libvlc.so.5"))
- || Files.isRegularFile(Path.of("/usr/lib/libvlc.so.5"))) {
- return true;
- }
- for (String bin : new String[]{"cvlc", "vlc"}) {
- try {
- Process p = new ProcessBuilder(bin, "--version").redirectErrorStream(true).start();
- if (p.waitFor(3, TimeUnit.SECONDS)) {
- return true;
- }
- } catch (Exception ignored) {
- // try next
- }
- }
- return false;
- }
-}
diff --git a/player/support/build.gradle.kts b/player/support/build.gradle.kts
new file mode 100644
index 00000000..c4c657ab
--- /dev/null
+++ b/player/support/build.gradle.kts
@@ -0,0 +1,7 @@
+plugins {
+ id("airplay.java-library")
+}
+
+dependencies {
+ implementation(libs.slf4j.api)
+}
diff --git a/lib/src/main/java/com/github/serezhka/airplay/lib/AppLogs.java b/player/support/src/main/java/com/github/serezhka/airplay/player/support/NativeProcessLog.java
similarity index 93%
rename from lib/src/main/java/com/github/serezhka/airplay/lib/AppLogs.java
rename to player/support/src/main/java/com/github/serezhka/airplay/player/support/NativeProcessLog.java
index 00bc3406..8e499563 100644
--- a/lib/src/main/java/com/github/serezhka/airplay/lib/AppLogs.java
+++ b/player/support/src/main/java/com/github/serezhka/airplay/player/support/NativeProcessLog.java
@@ -1,4 +1,4 @@
-package com.github.serezhka.airplay.lib;
+package com.github.serezhka.airplay.player.support;
import java.io.IOException;
import java.io.OutputStream;
@@ -8,7 +8,7 @@
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
-public final class AppLogs {
+public final class NativeProcessLog {
static {
try {
@@ -18,7 +18,7 @@ public final class AppLogs {
}
}
- private AppLogs() {
+ private NativeProcessLog() {
}
public static Path logsDirectory() {
diff --git a/player/vlc/build.gradle.kts b/player/vlc/build.gradle.kts
deleted file mode 100644
index 05a7e376..00000000
--- a/player/vlc/build.gradle.kts
+++ /dev/null
@@ -1,15 +0,0 @@
-plugins {
- id("airplay.java-library")
-}
-
-dependencies {
- implementation(projects.lib)
- implementation(projects.server)
- implementation(libs.slf4j.api)
- implementation(libs.dd.plist)
- implementation(libs.bundles.jna.full)
- implementation(libs.vlcj)
- implementation(libs.spf4j.core)
- implementation(libs.flatlaf)
- implementation(libs.avro)
-}
diff --git a/player/vlc/src/main/java/com/github/serezhka/airplay/player/vlc/VlcPlayer.java b/player/vlc/src/main/java/com/github/serezhka/airplay/player/vlc/VlcPlayer.java
deleted file mode 100644
index 1d023b34..00000000
--- a/player/vlc/src/main/java/com/github/serezhka/airplay/player/vlc/VlcPlayer.java
+++ /dev/null
@@ -1,273 +0,0 @@
-package com.github.serezhka.airplay.player.vlc;
-
-import com.formdev.flatlaf.FlatDarkLaf;
-import com.github.serezhka.airplay.lib.AudioStreamInfo;
-import com.github.serezhka.airplay.lib.AppLogs;
-import com.github.serezhka.airplay.lib.VideoStreamInfo;
-import com.github.serezhka.airplay.server.AirPlayConsumer;
-import lombok.extern.slf4j.Slf4j;
-import org.spf4j.io.PipedOutputStream;
-import uk.co.caprica.vlcj.factory.MediaPlayerFactory;
-import uk.co.caprica.vlcj.log.LogLevel;
-import uk.co.caprica.vlcj.log.NativeLog;
-import uk.co.caprica.vlcj.media.callback.nonseekable.NonSeekableInputStreamMedia;
-import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent;
-
-import javax.swing.*;
-import java.awt.event.WindowAdapter;
-import java.awt.event.WindowEvent;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.io.PrintStream;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-/**
- * VLC-backed AirPlay consumer (vlcj embedded UI by default).
- * Headless/CI uses {@code cvlc}/{@code vlc} stdin because vlcj native init can hang on Linux runners.
- */
-@Slf4j
-public class VlcPlayer implements AirPlayConsumer {
-
- static {
- if (!headless()) {
- FlatDarkLaf.setup();
- }
- }
-
- private final boolean headless;
- private final PrintStream vlcLog;
- private final AtomicBoolean closed = new AtomicBoolean();
- private final AtomicBoolean started = new AtomicBoolean();
-
- // GUI / vlcj path
- private MediaPlayerFactory mediaPlayerFactory;
- private NativeLog nativeLog;
- private EmbeddedMediaPlayerComponent mediaPlayerComponent;
- private JFrame window;
- private PipedOutputStream output;
- private InputStream input;
- private NonSeekableInputStreamMedia media;
-
- // CLI path
- private Process cliProcess;
- private OutputStream cliStdin;
-
- public VlcPlayer() {
- this.headless = headless();
- log.info("VLC debug log: {} (headless={})", AppLogs.playerLogFile("vlc"), headless);
- try {
- vlcLog = AppLogs.openAppendPrintStream("vlc");
- } catch (IOException e) {
- throw new IllegalStateException("Failed to open VLC debug log", e);
- }
-
- if (!headless) {
- initEmbedded();
- }
- }
-
- private void initEmbedded() {
- mediaPlayerFactory = new MediaPlayerFactory("-vv", "--demux=h264");
- nativeLog = mediaPlayerFactory.application().newLog();
- nativeLog.setLevel(LogLevel.WARNING);
- nativeLog.addLogListener((level, module, file, line, name, header, id, message) -> {
- vlcLog.printf("[%s] [%s] %s %s%n", level, module, name, message);
- log.debug("[VLCJ] [{}] [{}] {} {}", level, module, name, message);
- });
-
- output = new PipedOutputStream();
- input = output.getInputStream();
- media = new NonSeekableInputStreamMedia() {
- @Override
- protected long onGetSize() {
- return 0;
- }
-
- @Override
- protected InputStream onOpenStream() {
- return input;
- }
-
- @Override
- protected void onCloseStream(InputStream inputStream) throws IOException {
- inputStream.close();
- }
- };
-
- mediaPlayerComponent = new EmbeddedMediaPlayerComponent(mediaPlayerFactory, null, null, null, null);
- window = new JFrame("AirPlay player");
- window.setSize(800, 600);
- window.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
- window.addWindowListener(new WindowAdapter() {
- @Override
- public void windowClosing(WindowEvent e) {
- onVideoSrcDisconnect();
- }
- });
- window.setContentPane(mediaPlayerComponent);
- window.setVisible(true);
- }
-
- static boolean headless() {
- if (Boolean.parseBoolean(System.getProperty("airplay.vlc.headless", "false"))) {
- return true;
- }
- if (Boolean.parseBoolean(System.getenv().getOrDefault("AIRPLAY_VLC_HEADLESS", "false"))) {
- return true;
- }
- return System.getenv("CI") != null
- || System.getenv("GITHUB_ACTIONS") != null
- || Boolean.getBoolean("java.awt.headless");
- }
-
- private static String resolveCliBinary() {
- for (String candidate : List.of("cvlc", "vlc")) {
- try {
- Process p = new ProcessBuilder(candidate, "--version").redirectErrorStream(true).start();
- if (p.waitFor(3, TimeUnit.SECONDS)) {
- return candidate;
- }
- } catch (Exception ignored) {
- // try next
- }
- }
- throw new IllegalStateException("Neither cvlc nor vlc found on PATH");
- }
-
- @Override
- public void onVideoFormat(VideoStreamInfo videoStreamInfo) {
- if (!started.compareAndSet(false, true)) {
- return;
- }
- if (headless) {
- startCli();
- } else {
- mediaPlayerComponent.mediaPlayer().media().play(media);
- mediaPlayerComponent.mediaPlayer().controls().play();
- }
- }
-
- private void startCli() {
- String binary = resolveCliBinary();
- List cmd = new ArrayList<>();
- cmd.add(binary);
- cmd.add("--intf");
- cmd.add("dummy");
- cmd.add("--vout");
- cmd.add("dummy");
- cmd.add("--aout");
- cmd.add("dummy");
- cmd.add("--demux");
- cmd.add("h264");
- cmd.add("--play-and-exit");
- cmd.add("--no-video-title-show");
- cmd.add("-");
- try {
- ProcessBuilder pb = new ProcessBuilder(cmd);
- AppLogs.configureProcessLogging(pb, "vlc");
- cliProcess = pb.start();
- cliStdin = cliProcess.getOutputStream();
- log.info("Started headless {} for H264 stdin", binary);
- } catch (IOException e) {
- throw new IllegalStateException("Failed to start " + binary, e);
- }
- }
-
- @Override
- public void onVideo(byte[] bytes) {
- if (closed.get() || bytes == null || bytes.length == 0) {
- return;
- }
- if (!started.get()) {
- onVideoFormat(null);
- }
- try {
- if (headless) {
- cliStdin.write(bytes);
- cliStdin.flush();
- } else {
- output.write(bytes);
- output.flush();
- }
- } catch (IOException e) {
- vlcLog.printf("Failed to write video bytes: %s%n", e.getMessage());
- log.debug("Failed to write video bytes to VLC", e);
- }
- }
-
- @Override
- public void onVideoSrcDisconnect() {
- if (!closed.compareAndSet(false, true)) {
- return;
- }
- if (headless) {
- try {
- if (cliStdin != null) {
- cliStdin.close();
- }
- } catch (IOException ignored) {
- // shutting down
- }
- if (cliProcess != null) {
- cliProcess.destroy();
- try {
- cliProcess.waitFor(3, TimeUnit.SECONDS);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- cliProcess.destroyForcibly();
- }
- vlcLog.close();
- return;
- }
-
- try {
- mediaPlayerComponent.mediaPlayer().controls().stop();
- mediaPlayerComponent.release();
- } catch (Exception ignored) {
- // shutting down
- }
- try {
- nativeLog.release();
- } catch (Exception ignored) {
- // shutting down
- }
- try {
- mediaPlayerFactory.release();
- } catch (Exception ignored) {
- // shutting down
- }
- try {
- window.dispose();
- } catch (Exception ignored) {
- // shutting down
- }
- try {
- output.close();
- } catch (Exception ignored) {
- // shutting down
- }
- vlcLog.close();
- }
-
- @Override
- public void onAudioFormat(AudioStreamInfo audioStreamInfo) {
- }
-
- @Override
- public void onAudio(byte[] bytes) {
- }
-
- @Override
- public void onAudioSrcDisconnect() {
- }
-
- /** Exposed for harness assertions. */
- public boolean isCliAlive() {
- return cliProcess != null && cliProcess.isAlive();
- }
-}
diff --git a/lib/build.gradle.kts b/protocol/build.gradle.kts
similarity index 86%
rename from lib/build.gradle.kts
rename to protocol/build.gradle.kts
index b21df6b7..533f3866 100644
--- a/lib/build.gradle.kts
+++ b/protocol/build.gradle.kts
@@ -5,7 +5,6 @@ plugins {
dependencies {
implementation(libs.slf4j.api)
implementation(libs.dd.plist)
- implementation(libs.jmdns)
implementation(libs.eddsa)
implementation(libs.curve25519)
}
diff --git a/lib/src/main/java/com/github/serezhka/airplay/lib/AirPlay.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/FairPlaySession.java
similarity index 63%
rename from lib/src/main/java/com/github/serezhka/airplay/lib/AirPlay.java
rename to protocol/src/main/java/com/github/serezhka/airplay/protocol/FairPlaySession.java
index d30fb8a7..4a3e8135 100644
--- a/lib/src/main/java/com/github/serezhka/airplay/lib/AirPlay.java
+++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/FairPlaySession.java
@@ -1,27 +1,39 @@
-package com.github.serezhka.airplay.lib;
+package com.github.serezhka.airplay.protocol;
-import com.github.serezhka.airplay.lib.internal.*;
+import com.github.serezhka.airplay.protocol.fairplay.FairPlayAudioDecryptor;
+import com.github.serezhka.airplay.protocol.fairplay.FairPlayHandshake;
+import com.github.serezhka.airplay.protocol.fairplay.FairPlayVideoDecryptor;
+import com.github.serezhka.airplay.protocol.media.MediaStreamInfo;
+import com.github.serezhka.airplay.protocol.media.VideoStreamInfo;
+import com.github.serezhka.airplay.protocol.pairing.PairingHandshake;
+import com.github.serezhka.airplay.protocol.rtsp.RtspMediaSetup;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Optional;
/**
- * Responds on pairing setup, fairplay setup requests, decrypts data
+ * Crypto and RTSP media-setup state for one AirPlay connection.
*/
-public class AirPlay {
+public final class FairPlaySession {
- private final Pairing pairing;
- private final FairPlay fairplay;
- private final RTSP rtsp;
+ private final PairingHandshake pairing = new PairingHandshake();
+ private final FairPlayHandshake fairPlay = new FairPlayHandshake();
+ private final RtspMediaSetup rtsp = new RtspMediaSetup();
private FairPlayVideoDecryptor fairPlayVideoDecryptor;
private FairPlayAudioDecryptor fairPlayAudioDecryptor;
- public AirPlay() {
- pairing = new Pairing();
- fairplay = new FairPlay();
- rtsp = new RTSP();
+ public PairingHandshake pairing() {
+ return pairing;
+ }
+
+ public FairPlayHandshake fairPlay() {
+ return fairPlay;
+ }
+
+ public RtspMediaSetup rtsp() {
+ return rtsp;
}
/**
@@ -56,7 +68,7 @@ public boolean isPairVerified() {
* Writes fp-setup response bytes to output stream
*/
public void fairPlaySetup(InputStream in, OutputStream out) throws Exception {
- fairplay.fairPlaySetup(in, out);
+ fairPlay.fairPlaySetup(in, out);
}
/**
@@ -65,7 +77,13 @@ public void fairPlaySetup(InputStream in, OutputStream out) throws Exception {
* Sets encrypted EAS key and IV or retrieves media stream info
*/
public Optional rtspSetup(InputStream in) throws Exception {
- return rtsp.setup(in);
+ Optional mediaStreamInfo = rtsp.setup(in);
+ // YouTube ads / next item send a new type-110 stream with a new streamConnectionID.
+ // Reusing the previous AES-CTR decryptor yields garbage NALs for the new stream.
+ if (mediaStreamInfo.isPresent() && mediaStreamInfo.get() instanceof VideoStreamInfo) {
+ fairPlayVideoDecryptor = null;
+ }
+ return mediaStreamInfo;
}
/**
@@ -74,12 +92,16 @@ public Optional rtspSetup(InputStream in) throws Exception {
* Retrieves media stream info
*/
public Optional rtspTeardown(InputStream in) throws Exception {
- return rtsp.teardown(in);
+ Optional mediaStreamInfo = rtsp.teardown(in);
+ if (mediaStreamInfo.isPresent() && mediaStreamInfo.get() instanceof VideoStreamInfo) {
+ fairPlayVideoDecryptor = null;
+ }
+ return mediaStreamInfo;
}
public byte[] getFairPlayAesKey() {
- return fairplay.decryptAesKey(rtsp.getEkey());
+ return fairPlay.decryptAesKey(rtsp.getEkey());
}
/**
diff --git a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/FairPlayAudioDecryptor.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayAudioDecryptor.java
similarity index 95%
rename from lib/src/main/java/com/github/serezhka/airplay/lib/internal/FairPlayAudioDecryptor.java
rename to protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayAudioDecryptor.java
index 3edb46b6..30da3b43 100644
--- a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/FairPlayAudioDecryptor.java
+++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayAudioDecryptor.java
@@ -1,4 +1,4 @@
-package com.github.serezhka.airplay.lib.internal;
+package com.github.serezhka.airplay.protocol.fairplay;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
diff --git a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/ModifiedMD5.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayDigest.java
similarity index 97%
rename from lib/src/main/java/com/github/serezhka/airplay/lib/internal/ModifiedMD5.java
rename to protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayDigest.java
index 440fa754..3a91a28e 100644
--- a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/ModifiedMD5.java
+++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayDigest.java
@@ -1,9 +1,9 @@
-package com.github.serezhka.airplay.lib.internal;
+package com.github.serezhka.airplay.protocol.fairplay;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
-public class ModifiedMD5 {
+public class FairPlayDigest {
private final int[] shift = {7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
diff --git a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/FairPlay.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayHandshake.java
similarity index 96%
rename from lib/src/main/java/com/github/serezhka/airplay/lib/internal/FairPlay.java
rename to protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayHandshake.java
index 41020e01..9cb7104e 100644
--- a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/FairPlay.java
+++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayHandshake.java
@@ -1,4 +1,4 @@
-package com.github.serezhka.airplay.lib.internal;
+package com.github.serezhka.airplay.protocol.fairplay;
import lombok.extern.slf4j.Slf4j;
import net.i2p.crypto.eddsa.Utils;
@@ -8,9 +8,9 @@
import java.io.OutputStream;
@Slf4j
-public class FairPlay {
+public class FairPlayHandshake {
- private final OmgHax omgHax = new OmgHax();
+ private final SapKeySchedule omgHax = new SapKeySchedule();
private final byte[] keyMsg = new byte[164];
diff --git a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/HandGarble.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayScrambler.java
similarity index 99%
rename from lib/src/main/java/com/github/serezhka/airplay/lib/internal/HandGarble.java
rename to protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayScrambler.java
index 1a20021f..85614004 100644
--- a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/HandGarble.java
+++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayScrambler.java
@@ -1,6 +1,6 @@
-package com.github.serezhka.airplay.lib.internal;
+package com.github.serezhka.airplay.protocol.fairplay;
-public class HandGarble {
+public class FairPlayScrambler {
// I do not know why it is doing all of this, and there is still a possibility for a gremlin or two to be lurking in the background
// I DO know it is not trivial. It could be purely random garbage, of course.
diff --git a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/FairPlayVideoDecryptor.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayVideoDecryptor.java
similarity index 98%
rename from lib/src/main/java/com/github/serezhka/airplay/lib/internal/FairPlayVideoDecryptor.java
rename to protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayVideoDecryptor.java
index a677b416..3fc42fe1 100644
--- a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/FairPlayVideoDecryptor.java
+++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayVideoDecryptor.java
@@ -1,4 +1,4 @@
-package com.github.serezhka.airplay.lib.internal;
+package com.github.serezhka.airplay.protocol.fairplay;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
diff --git a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/SapHash.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/SapHash.java
similarity index 96%
rename from lib/src/main/java/com/github/serezhka/airplay/lib/internal/SapHash.java
rename to protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/SapHash.java
index b8f54c40..f9100181 100644
--- a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/SapHash.java
+++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/SapHash.java
@@ -1,11 +1,11 @@
-package com.github.serezhka.airplay.lib.internal;
+package com.github.serezhka.airplay.protocol.fairplay;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
public class SapHash {
- private final HandGarble handGarble = new HandGarble();
+ private final FairPlayScrambler handGarble = new FairPlayScrambler();
private byte rol8(byte input, int count) {
return (byte) (((input << count) & 0xff) | (input & 0xff) >> (8 - count));
diff --git a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/OmgHax.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/SapKeySchedule.java
similarity index 98%
rename from lib/src/main/java/com/github/serezhka/airplay/lib/internal/OmgHax.java
rename to protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/SapKeySchedule.java
index 72a385b7..acc3977b 100644
--- a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/OmgHax.java
+++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/SapKeySchedule.java
@@ -1,14 +1,14 @@
-package com.github.serezhka.airplay.lib.internal;
+package com.github.serezhka.airplay.protocol.fairplay;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
-import static com.github.serezhka.airplay.lib.internal.OmgHaxConst.*;
+import static com.github.serezhka.airplay.protocol.fairplay.SapTables.*;
-public class OmgHax {
+public class SapKeySchedule {
- private final ModifiedMD5 modifiedMD5 = new ModifiedMD5();
+ private final FairPlayDigest modifiedMD5 = new FairPlayDigest();
private final SapHash sapHash = new SapHash();
void decryptAesKey(byte[] message3, byte[] cipherText, byte[] keyOut) {
diff --git a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/OmgHaxConst.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/SapTables.java
similarity index 87%
rename from lib/src/main/java/com/github/serezhka/airplay/lib/internal/OmgHaxConst.java
rename to protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/SapTables.java
index 24d72bf9..fe1fcab4 100644
--- a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/OmgHaxConst.java
+++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/fairplay/SapTables.java
@@ -1,11 +1,11 @@
-package com.github.serezhka.airplay.lib.internal;
+package com.github.serezhka.airplay.protocol.fairplay;
import java.io.*;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
-public class OmgHaxConst {
+public class SapTables {
static final byte[] z_key = {26, 100, -7, 96, 108, -29, 1, -87, 84, 72, 27, -44, -85, -127, -4, -58};
static final byte[] x_key = {-114, -70, 7, -52, -74, 90, -10, 32, 51, -49, -8, 66, -27, -43, 90, 125};
@@ -41,16 +41,16 @@ public class OmgHaxConst {
static {
try {
- table_s1 = readBytes(OmgHaxConst.class.getResource("/table_s1"));
- table_s2 = readBytes(OmgHaxConst.class.getResource("/table_s2"));
- table_s3 = readBytes(OmgHaxConst.class.getResource("/table_s3"));
- table_s4 = readBytes(OmgHaxConst.class.getResource("/table_s4"));
- table_s5 = readInts(OmgHaxConst.class.getResource("/table_s5"));
- table_s6 = readInts(OmgHaxConst.class.getResource("/table_s6"));
- table_s7 = readInts(OmgHaxConst.class.getResource("/table_s7"));
- table_s8 = readInts(OmgHaxConst.class.getResource("/table_s8"));
- table_s9 = readInts(OmgHaxConst.class.getResource("/table_s9"));
- table_s10 = readBytes(OmgHaxConst.class.getResource("/table_s10"));
+ table_s1 = readBytes(SapTables.class.getResource("/table_s1"));
+ table_s2 = readBytes(SapTables.class.getResource("/table_s2"));
+ table_s3 = readBytes(SapTables.class.getResource("/table_s3"));
+ table_s4 = readBytes(SapTables.class.getResource("/table_s4"));
+ table_s5 = readInts(SapTables.class.getResource("/table_s5"));
+ table_s6 = readInts(SapTables.class.getResource("/table_s6"));
+ table_s7 = readInts(SapTables.class.getResource("/table_s7"));
+ table_s8 = readInts(SapTables.class.getResource("/table_s8"));
+ table_s9 = readInts(SapTables.class.getResource("/table_s9"));
+ table_s10 = readBytes(SapTables.class.getResource("/table_s10"));
} catch (Exception e) {
throw new RuntimeException("Init failed", e);
}
@@ -72,7 +72,7 @@ private static byte[] readBytes(URL url) throws IOException {
}
/**
- * Files.newBufferedReader(Paths.get(OmgHaxConst.class.getClassLoader().getResource("table_s5").toURI()))
+ * Files.newBufferedReader(Paths.get(SapTables.class.getClassLoader().getResource("table_s5").toURI()))
* .lines().map(Long::decode).mapToInt(Long::intValue).toArray();
* Doesn't work !!! throws java.nio.file.FileSystemNotFoundException
* jar:file:/../java-airplay-lib/build/libs/java-airplay-lib-1.0-SNAPSHOT-all.jar!/table_s1
diff --git a/lib/src/main/java/com/github/serezhka/airplay/lib/AudioStreamInfo.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/media/AudioStreamInfo.java
similarity index 98%
rename from lib/src/main/java/com/github/serezhka/airplay/lib/AudioStreamInfo.java
rename to protocol/src/main/java/com/github/serezhka/airplay/protocol/media/AudioStreamInfo.java
index 8168bf42..637f233a 100644
--- a/lib/src/main/java/com/github/serezhka/airplay/lib/AudioStreamInfo.java
+++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/media/AudioStreamInfo.java
@@ -1,4 +1,4 @@
-package com.github.serezhka.airplay.lib;
+package com.github.serezhka.airplay.protocol.media;
public class AudioStreamInfo implements MediaStreamInfo {
diff --git a/protocol/src/main/java/com/github/serezhka/airplay/protocol/media/EndListDuration.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/media/EndListDuration.java
new file mode 100644
index 00000000..23427128
--- /dev/null
+++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/media/EndListDuration.java
@@ -0,0 +1,33 @@
+package com.github.serezhka.airplay.protocol.media;
+
+/**
+ * Sums {@code #EXTINF} durations from a VOD media playlist that ends with {@code #EXT-X-ENDLIST}.
+ * Sliding-window live playlists (no ENDLIST) return {@code 0} so callers do not inflate ad length.
+ */
+public final class EndListDuration {
+
+ private EndListDuration() {
+ }
+
+ /**
+ * @return total EXTINF seconds, or {@code 0} when content is null, not ENDLIST, or has no EXTINF
+ */
+ public static double sumSeconds(String content) {
+ if (content == null || !content.contains("#EXT-X-ENDLIST")) {
+ return 0;
+ }
+ double sum = 0;
+ for (String line : content.split("\n")) {
+ if (!line.startsWith("#EXTINF:")) {
+ continue;
+ }
+ String value = line.substring("#EXTINF:".length()).split(",", 2)[0].trim();
+ try {
+ sum += Double.parseDouble(value);
+ } catch (NumberFormatException ignored) {
+ // skip malformed tags
+ }
+ }
+ return sum;
+ }
+}
diff --git a/lib/src/main/java/com/github/serezhka/airplay/lib/MediaStreamInfo.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/media/MediaStreamInfo.java
similarity index 71%
rename from lib/src/main/java/com/github/serezhka/airplay/lib/MediaStreamInfo.java
rename to protocol/src/main/java/com/github/serezhka/airplay/protocol/media/MediaStreamInfo.java
index 98abff87..0f0e0be8 100644
--- a/lib/src/main/java/com/github/serezhka/airplay/lib/MediaStreamInfo.java
+++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/media/MediaStreamInfo.java
@@ -1,4 +1,4 @@
-package com.github.serezhka.airplay.lib;
+package com.github.serezhka.airplay.protocol.media;
public interface MediaStreamInfo {
diff --git a/lib/src/main/java/com/github/serezhka/airplay/lib/VideoStreamInfo.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/media/VideoStreamInfo.java
similarity index 92%
rename from lib/src/main/java/com/github/serezhka/airplay/lib/VideoStreamInfo.java
rename to protocol/src/main/java/com/github/serezhka/airplay/protocol/media/VideoStreamInfo.java
index faa412b9..85dbe94b 100644
--- a/lib/src/main/java/com/github/serezhka/airplay/lib/VideoStreamInfo.java
+++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/media/VideoStreamInfo.java
@@ -1,4 +1,4 @@
-package com.github.serezhka.airplay.lib;
+package com.github.serezhka.airplay.protocol.media;
public class VideoStreamInfo implements MediaStreamInfo {
diff --git a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/Pairing.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/pairing/PairingHandshake.java
similarity index 97%
rename from lib/src/main/java/com/github/serezhka/airplay/lib/internal/Pairing.java
rename to protocol/src/main/java/com/github/serezhka/airplay/protocol/pairing/PairingHandshake.java
index db3f1fe9..eb4ab913 100644
--- a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/Pairing.java
+++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/pairing/PairingHandshake.java
@@ -1,4 +1,4 @@
-package com.github.serezhka.airplay.lib.internal;
+package com.github.serezhka.airplay.protocol.pairing;
import lombok.extern.slf4j.Slf4j;
import net.i2p.crypto.eddsa.EdDSAEngine;
@@ -24,7 +24,7 @@
import java.util.Arrays;
@Slf4j
-public class Pairing {
+public class PairingHandshake {
private final KeyPair keyPair;
@@ -35,7 +35,7 @@ public class Pairing {
private boolean pairVerified;
- public Pairing() {
+ public PairingHandshake() {
this.keyPair = new KeyPairGenerator().generateKeyPair();
}
diff --git a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/RTSP.java b/protocol/src/main/java/com/github/serezhka/airplay/protocol/rtsp/RtspMediaSetup.java
similarity index 93%
rename from lib/src/main/java/com/github/serezhka/airplay/lib/internal/RTSP.java
rename to protocol/src/main/java/com/github/serezhka/airplay/protocol/rtsp/RtspMediaSetup.java
index 547b2055..2e7a1ae0 100644
--- a/lib/src/main/java/com/github/serezhka/airplay/lib/internal/RTSP.java
+++ b/protocol/src/main/java/com/github/serezhka/airplay/protocol/rtsp/RtspMediaSetup.java
@@ -1,10 +1,10 @@
-package com.github.serezhka.airplay.lib.internal;
+package com.github.serezhka.airplay.protocol.rtsp;
import com.dd.plist.BinaryPropertyListParser;
import com.dd.plist.NSDictionary;
-import com.github.serezhka.airplay.lib.AudioStreamInfo;
-import com.github.serezhka.airplay.lib.MediaStreamInfo;
-import com.github.serezhka.airplay.lib.VideoStreamInfo;
+import com.github.serezhka.airplay.protocol.media.AudioStreamInfo;
+import com.github.serezhka.airplay.protocol.media.MediaStreamInfo;
+import com.github.serezhka.airplay.protocol.media.VideoStreamInfo;
import lombok.extern.slf4j.Slf4j;
import net.i2p.crypto.eddsa.Utils;
@@ -13,7 +13,7 @@
import java.util.Optional;
@Slf4j
-public class RTSP {
+public class RtspMediaSetup {
private byte[] ekey;
private byte[] eiv;
diff --git a/lib/src/main/resources/table_s1 b/protocol/src/main/resources/table_s1
similarity index 100%
rename from lib/src/main/resources/table_s1
rename to protocol/src/main/resources/table_s1
diff --git a/lib/src/main/resources/table_s10 b/protocol/src/main/resources/table_s10
similarity index 100%
rename from lib/src/main/resources/table_s10
rename to protocol/src/main/resources/table_s10
diff --git a/lib/src/main/resources/table_s2 b/protocol/src/main/resources/table_s2
similarity index 100%
rename from lib/src/main/resources/table_s2
rename to protocol/src/main/resources/table_s2
diff --git a/lib/src/main/resources/table_s3 b/protocol/src/main/resources/table_s3
similarity index 100%
rename from lib/src/main/resources/table_s3
rename to protocol/src/main/resources/table_s3
diff --git a/lib/src/main/resources/table_s4 b/protocol/src/main/resources/table_s4
similarity index 100%
rename from lib/src/main/resources/table_s4
rename to protocol/src/main/resources/table_s4
diff --git a/lib/src/main/resources/table_s5 b/protocol/src/main/resources/table_s5
similarity index 100%
rename from lib/src/main/resources/table_s5
rename to protocol/src/main/resources/table_s5
diff --git a/lib/src/main/resources/table_s6 b/protocol/src/main/resources/table_s6
similarity index 100%
rename from lib/src/main/resources/table_s6
rename to protocol/src/main/resources/table_s6
diff --git a/lib/src/main/resources/table_s7 b/protocol/src/main/resources/table_s7
similarity index 100%
rename from lib/src/main/resources/table_s7
rename to protocol/src/main/resources/table_s7
diff --git a/lib/src/main/resources/table_s8 b/protocol/src/main/resources/table_s8
similarity index 100%
rename from lib/src/main/resources/table_s8
rename to protocol/src/main/resources/table_s8
diff --git a/lib/src/main/resources/table_s9 b/protocol/src/main/resources/table_s9
similarity index 100%
rename from lib/src/main/resources/table_s9
rename to protocol/src/main/resources/table_s9
diff --git a/lib/src/test/java/com/github/serezhka/airplay/lib/internal/AirPlayFairPlayTest.java b/protocol/src/test/java/com/github/serezhka/airplay/protocol/FairPlaySessionFairPlayTest.java
similarity index 95%
rename from lib/src/test/java/com/github/serezhka/airplay/lib/internal/AirPlayFairPlayTest.java
rename to protocol/src/test/java/com/github/serezhka/airplay/protocol/FairPlaySessionFairPlayTest.java
index cfa4e788..0f7e5d02 100644
--- a/lib/src/test/java/com/github/serezhka/airplay/lib/internal/AirPlayFairPlayTest.java
+++ b/protocol/src/test/java/com/github/serezhka/airplay/protocol/FairPlaySessionFairPlayTest.java
@@ -1,9 +1,10 @@
-package com.github.serezhka.airplay.lib.internal;
+package com.github.serezhka.airplay.protocol;
import com.dd.plist.BinaryPropertyListWriter;
import com.dd.plist.NSArray;
import com.dd.plist.NSDictionary;
-import com.github.serezhka.airplay.lib.AirPlay;
+import com.github.serezhka.airplay.protocol.fairplay.FairPlayVideoDecryptor;
+import com.github.serezhka.airplay.protocol.FairPlaySession;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
@@ -18,7 +19,7 @@
class AirPlayFairPlayTest {
- private final AirPlay airPlay = new AirPlay();
+ private final FairPlaySession airPlay = new FairPlaySession();
/**
* Actually don't know how fairplay works
diff --git a/lib/src/test/java/com/github/serezhka/airplay/lib/internal/AirPlayPairingTest.java b/protocol/src/test/java/com/github/serezhka/airplay/protocol/FairPlaySessionPairingTest.java
similarity index 95%
rename from lib/src/test/java/com/github/serezhka/airplay/lib/internal/AirPlayPairingTest.java
rename to protocol/src/test/java/com/github/serezhka/airplay/protocol/FairPlaySessionPairingTest.java
index 46b6f502..dfa967da 100644
--- a/lib/src/test/java/com/github/serezhka/airplay/lib/internal/AirPlayPairingTest.java
+++ b/protocol/src/test/java/com/github/serezhka/airplay/protocol/FairPlaySessionPairingTest.java
@@ -1,6 +1,6 @@
-package com.github.serezhka.airplay.lib.internal;
+package com.github.serezhka.airplay.protocol;
-import com.github.serezhka.airplay.lib.AirPlay;
+import com.github.serezhka.airplay.protocol.FairPlaySession;
import net.i2p.crypto.eddsa.EdDSAEngine;
import net.i2p.crypto.eddsa.EdDSAPublicKey;
import net.i2p.crypto.eddsa.KeyPairGenerator;
@@ -23,7 +23,7 @@
class AirPlayPairingTest {
- private final AirPlay airPlay = new AirPlay();
+ private final FairPlaySession airPlay = new FairPlaySession();
@Test
void pairingTest() throws Exception {
diff --git a/lib/src/test/java/com/github/serezhka/airplay/lib/internal/HandGarbleTest.java b/protocol/src/test/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayScramblerTest.java
similarity index 99%
rename from lib/src/test/java/com/github/serezhka/airplay/lib/internal/HandGarbleTest.java
rename to protocol/src/test/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayScramblerTest.java
index 22c997c5..8aa0754a 100644
--- a/lib/src/test/java/com/github/serezhka/airplay/lib/internal/HandGarbleTest.java
+++ b/protocol/src/test/java/com/github/serezhka/airplay/protocol/fairplay/FairPlayScramblerTest.java
@@ -1,12 +1,12 @@
-package com.github.serezhka.airplay.lib.internal;
+package com.github.serezhka.airplay.protocol.fairplay;
-import com.github.serezhka.airplay.lib.internal.HandGarble;
+import com.github.serezhka.airplay.protocol.fairplay.FairPlayScrambler;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
class HandGarbleTest {
- private final HandGarble handGarble = new HandGarble();
+ private final FairPlayScrambler handGarble = new FairPlayScrambler();
@Test
void garble1Test() {
diff --git a/lib/src/test/java/com/github/serezhka/airplay/lib/internal/SapHashTest.java b/protocol/src/test/java/com/github/serezhka/airplay/protocol/fairplay/SapHashTest.java
similarity index 98%
rename from lib/src/test/java/com/github/serezhka/airplay/lib/internal/SapHashTest.java
rename to protocol/src/test/java/com/github/serezhka/airplay/protocol/fairplay/SapHashTest.java
index f32d2145..e06b364a 100644
--- a/lib/src/test/java/com/github/serezhka/airplay/lib/internal/SapHashTest.java
+++ b/protocol/src/test/java/com/github/serezhka/airplay/protocol/fairplay/SapHashTest.java
@@ -1,6 +1,6 @@
-package com.github.serezhka.airplay.lib.internal;
+package com.github.serezhka.airplay.protocol.fairplay;
-import com.github.serezhka.airplay.lib.internal.SapHash;
+import com.github.serezhka.airplay.protocol.fairplay.SapHash;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
diff --git a/lib/src/test/java/com/github/serezhka/airplay/lib/internal/OmgHaxTest.java b/protocol/src/test/java/com/github/serezhka/airplay/protocol/fairplay/SapKeyScheduleTest.java
similarity index 95%
rename from lib/src/test/java/com/github/serezhka/airplay/lib/internal/OmgHaxTest.java
rename to protocol/src/test/java/com/github/serezhka/airplay/protocol/fairplay/SapKeyScheduleTest.java
index f38436fe..5d52f301 100644
--- a/lib/src/test/java/com/github/serezhka/airplay/lib/internal/OmgHaxTest.java
+++ b/protocol/src/test/java/com/github/serezhka/airplay/protocol/fairplay/SapKeyScheduleTest.java
@@ -1,21 +1,21 @@
-package com.github.serezhka.airplay.lib.internal;
+package com.github.serezhka.airplay.protocol.fairplay;
-import com.github.serezhka.airplay.lib.internal.OmgHax;
-import com.github.serezhka.airplay.lib.internal.OmgHaxConst;
+import com.github.serezhka.airplay.protocol.fairplay.SapKeySchedule;
+import com.github.serezhka.airplay.protocol.fairplay.SapTables;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
class OmgHaxTest {
- private final OmgHax omgHax = new OmgHax();
+ private final SapKeySchedule omgHax = new SapKeySchedule();
@Test
void generateSessionKey() {
byte[] messageIn = {70, 80, 76, 89, 3, 1, 3, 0, 0, 0, 0, -104, 0, -113, 26, -100, -40, -92, -10, 52, 109, 20, 120, 6, -62, -67, -118, 75, -47, -71, -109, -45, -61, 106, -95, 1, 36, -104, -7, 78, -1, -13, 70, 123, -49, 27, 49, -104, 98, 92, -94, 69, -114, 62, -48, 30, -35, 53, -25, 41, 53, 125, -7, 75, -128, -51, 10, -50, 35, 84, -42, -116, -29, 127, 94, 24, -16, -49, -46, 109, 65, 103, 21, 63, -64, -76, 54, 35, 22, 111, 8, -58, 111, -45, 1, 56, 14, -80, -98, -97, -115, -24, 59, -46, -82, -57, -92, 1, -15, -5, -67, -13, 46, 10, -43, 81, -24, 121, 63, -25, -63, 25, 35, 51, -103, -91, 53, 76, -59, 67, 7, 30, -68, -50, -32, -84, -123, 34, -82, 27, -85, 51, -44, 65, -60, 120, -11, 99, -50, -3, 66, 117, -5, 85, 90, 58, -29, 58, -40, -71, -7, -108, -7, -75};
byte[] sessionKey = new byte[16];
- omgHax.generate_session_key(OmgHaxConst.default_sap, messageIn, sessionKey);
+ omgHax.generate_session_key(SapTables.default_sap, messageIn, sessionKey);
assertArrayEquals(new byte[]{39, 110, -67, 89, -58, 116, 70, 37, 101, -9, -9, -68, -58, 68, 4, 50}, sessionKey);
}
@@ -42,7 +42,7 @@ void decryptMessage2Text() {
void generateKeySchedule1Test() {
int[][] keySchedule = new int[11][4];
- omgHax.generate_key_schedule(OmgHaxConst.initial_session_key, keySchedule);
+ omgHax.generate_key_schedule(SapTables.initial_session_key, keySchedule);
assertArrayEquals(new int[]{-665135092, 2094846048, 1974007022, -129415421}, keySchedule[0]);
assertArrayEquals(new int[]{1657169001, 505066505, 1806844135, -1812264988}, keySchedule[1]);
diff --git a/protocol/src/test/java/com/github/serezhka/airplay/protocol/media/EndListDurationTest.java b/protocol/src/test/java/com/github/serezhka/airplay/protocol/media/EndListDurationTest.java
new file mode 100644
index 00000000..ed735e71
--- /dev/null
+++ b/protocol/src/test/java/com/github/serezhka/airplay/protocol/media/EndListDurationTest.java
@@ -0,0 +1,39 @@
+package com.github.serezhka.airplay.protocol.media;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class HlsEndListDurationTest {
+
+ @Test
+ void sumsEndListExtinf() {
+ String vod = """
+ #EXTM3U
+ #EXTINF:6.5,
+ a.ts
+ #EXTINF:7.1,
+ b.ts
+ #EXT-X-ENDLIST
+ """;
+ assertEquals(13.6, EndListDuration.sumSeconds(vod), 0.001);
+ }
+
+ @Test
+ void liveWithoutEndListIsZero() {
+ String live = """
+ #EXTM3U
+ #EXTINF:6.0,
+ a.ts
+ #EXTINF:6.0,
+ b.ts
+ """;
+ assertEquals(0, EndListDuration.sumSeconds(live), 0.001);
+ }
+
+ @Test
+ void nullOrEmptyIsZero() {
+ assertEquals(0, EndListDuration.sumSeconds(null), 0.001);
+ assertEquals(0, EndListDuration.sumSeconds(""), 0.001);
+ }
+}
diff --git a/lib/src/test/resources/encrypted_payload b/protocol/src/test/resources/encrypted_payload
similarity index 100%
rename from lib/src/test/resources/encrypted_payload
rename to protocol/src/test/resources/encrypted_payload
diff --git a/server/build.gradle.kts b/server/build.gradle.kts
index aa84d9c3..d019c899 100644
--- a/server/build.gradle.kts
+++ b/server/build.gradle.kts
@@ -3,10 +3,13 @@ plugins {
}
dependencies {
- implementation(projects.lib)
+ implementation(projects.protocol)
+ implementation(libs.jmdns)
implementation(libs.slf4j.api)
implementation(libs.dd.plist)
implementation(libs.netty.all)
implementation(libs.eddsa)
implementation(libs.m3u8.parser)
+
+ testImplementation(libs.archunit.junit5)
}
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/AirPlayConsumer.java b/server/src/main/java/com/github/serezhka/airplay/server/AirPlayConsumer.java
deleted file mode 100644
index 9aaee681..00000000
--- a/server/src/main/java/com/github/serezhka/airplay/server/AirPlayConsumer.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.github.serezhka.airplay.server;
-
-import com.github.serezhka.airplay.lib.AudioStreamInfo;
-import com.github.serezhka.airplay.lib.VideoStreamInfo;
-import lombok.Getter;
-import lombok.RequiredArgsConstructor;
-
-public interface AirPlayConsumer {
-
- void onVideoFormat(VideoStreamInfo videoStreamInfo);
-
- void onVideo(byte[] bytes);
-
- void onVideoSrcDisconnect();
-
- void onAudioFormat(AudioStreamInfo audioStreamInfo);
-
- void onAudio(byte[] bytes);
-
- void onAudioSrcDisconnect();
-
- // HLS stuff, youtube
- default void onMediaPlaylist(String playlistUri) {
- }
-
- default void onMediaPlaylistContent(String playlistUri, String content) {
- }
-
- default void onMediaPlaylistRemove() {
- }
-
- default void onMediaPlaylistPause() {
- }
-
- default void onMediaPlaylistResume() {
- }
-
- default void onControlExchange(ControlExchange exchange) {
- }
-
- default PlaybackInfo playbackInfo() {
- return new PlaybackInfo(0, 0);
- }
-
- record PlaybackInfo(double duration, double position) {
- }
-}
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/AirPlayServer.java b/server/src/main/java/com/github/serezhka/airplay/server/AirPlayServer.java
index ceebc520..ed52f034 100644
--- a/server/src/main/java/com/github/serezhka/airplay/server/AirPlayServer.java
+++ b/server/src/main/java/com/github/serezhka/airplay/server/AirPlayServer.java
@@ -1,15 +1,15 @@
package com.github.serezhka.airplay.server;
-import com.github.serezhka.airplay.lib.AirPlayBonjour;
+import com.github.serezhka.airplay.server.discovery.MdnsAdvertiser;
import com.github.serezhka.airplay.server.internal.ControlServer;
public class AirPlayServer {
- private final AirPlayBonjour airPlayBonjour;
+ private final MdnsAdvertiser airPlayBonjour;
private final ControlServer controlServer;
- public AirPlayServer(AirPlayConfig airPlayConfig, AirPlayConsumer airPlayConsumer) {
- airPlayBonjour = new AirPlayBonjour(airPlayConfig.getServerName());
+ public AirPlayServer(AirPlayConfig airPlayConfig, Playback airPlayConsumer) {
+ airPlayBonjour = new MdnsAdvertiser(airPlayConfig.getServerName());
controlServer = new ControlServer(airPlayConfig, airPlayConsumer);
}
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/Playback.java b/server/src/main/java/com/github/serezhka/airplay/server/Playback.java
new file mode 100644
index 00000000..9e607067
--- /dev/null
+++ b/server/src/main/java/com/github/serezhka/airplay/server/Playback.java
@@ -0,0 +1,73 @@
+package com.github.serezhka.airplay.server;
+
+import com.github.serezhka.airplay.protocol.media.AudioStreamInfo;
+import com.github.serezhka.airplay.protocol.media.VideoStreamInfo;
+
+public interface Playback {
+
+ void onVideoFormat(VideoStreamInfo videoStreamInfo);
+
+ void onVideo(byte[] bytes);
+
+ void onVideoSrcDisconnect();
+
+ void onAudioFormat(AudioStreamInfo audioStreamInfo);
+
+ void onAudio(byte[] bytes);
+
+ void onAudioSrcDisconnect();
+
+ default void onPlaylist(String playlistUri) {
+ }
+
+ default void onPlaylistContent(String playlistUri, String content) {
+ }
+
+ default void onPlaylistRemoved() {
+ }
+
+ default void onPause() {
+ }
+
+ default void onResume() {
+ }
+
+ default void onSeek(double positionSeconds) {
+ }
+
+ /** Linear volume in {@code [0, 1]}. RTSP dB is converted by the control layer. */
+ default void onVolume(double volumeLinear) {
+ }
+
+ default double volume() {
+ return 1.0;
+ }
+
+ default void onControlExchange(ControlExchange exchange) {
+ }
+
+ default Playback.Info info() {
+ return Playback.Info.idle();
+ }
+
+ /** Server registers one observer. The player calls {@link Observer#onEnded()} at VOD end. */
+ default void setObserver(Observer observer) {
+ }
+
+ interface Observer {
+ void onEnded();
+
+ Observer NONE = () -> {};
+ }
+
+ /** {@code rate}: {@code 0} paused, {@code 1} playing ({@code /playback-info}, {@code /rate}). */
+ record Info(double duration, double position, double rate) {
+ public Info(double duration, double position) {
+ this(duration, position, 1);
+ }
+
+ public static Info idle() {
+ return new Info(0, 0, 1);
+ }
+ }
+}
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiver.java b/server/src/main/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiver.java
new file mode 100644
index 00000000..6802966c
--- /dev/null
+++ b/server/src/main/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiver.java
@@ -0,0 +1,32 @@
+package com.github.serezhka.airplay.server.discovery;
+
+/**
+ * Receiver advertisement profile (mDNS TXT + {@code GET /info}).
+ *
+ * Historic receiver mask used when YouTube HLS support was first wired:
+ * {@code 0x5A7FFFF7,0x1E} (same value as {@code GET /info} features
+ * {@code 130367356919}).
+ */
+public final class AdvertisedReceiver {
+
+ public static final int FEATURES_LO = 0x5A7FFFF7;
+ public static final int FEATURES_HI = 0x1E;
+
+ /** 64-bit features for {@code GET /info} plist. */
+ public static final long FEATURES = ((long) FEATURES_HI << 32) | (FEATURES_LO & 0xFFFFFFFFL);
+
+ public static final String FEATURES_TXT = String.format("0x%X,0x%X", FEATURES_LO, FEATURES_HI);
+
+ public static final String SOURCE_VERSION = "220.68";
+ public static final String MODEL = "AppleTV3,2";
+ public static final int STATUS_FLAGS = 0x44;
+ public static final String MDNS_FLAGS = "0x44";
+ public static final int VV = 2;
+
+ private AdvertisedReceiver() {
+ }
+
+ public static String airTunesServerHeader() {
+ return "AirTunes/" + SOURCE_VERSION;
+ }
+}
diff --git a/lib/src/main/java/com/github/serezhka/airplay/lib/AirPlayBonjour.java b/server/src/main/java/com/github/serezhka/airplay/server/discovery/MdnsAdvertiser.java
similarity index 85%
rename from lib/src/main/java/com/github/serezhka/airplay/lib/AirPlayBonjour.java
rename to server/src/main/java/com/github/serezhka/airplay/server/discovery/MdnsAdvertiser.java
index 34b76971..576ac9b8 100644
--- a/lib/src/main/java/com/github/serezhka/airplay/lib/AirPlayBonjour.java
+++ b/server/src/main/java/com/github/serezhka/airplay/server/discovery/MdnsAdvertiser.java
@@ -1,4 +1,4 @@
-package com.github.serezhka.airplay.lib;
+package com.github.serezhka.airplay.server.discovery;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -21,7 +21,7 @@
*/
@Slf4j
@RequiredArgsConstructor
-public class AirPlayBonjour {
+public class MdnsAdvertiser {
private static final String AIRPLAY_SERVICE_TYPE = "._airplay._tcp.local";
private static final String AIRTUNES_SERVICE_TYPE = "._raop._tcp.local";
@@ -68,13 +68,12 @@ public void stop() {
private Map airPlayMDNSProps(String deviceId) {
HashMap airPlayMDNSProps = new HashMap<>();
airPlayMDNSProps.put("deviceid", deviceId);
- // Bit 27 (legacy pairing) off: clients skip pair-pin-start and go FairPlay → SETUP.
- // Legacy Pairing code remains for senders that still use it.
- airPlayMDNSProps.put("features", "0x527FFEE6,0x0");
- airPlayMDNSProps.put("srcvers", "220.68");
- airPlayMDNSProps.put("flags", "0x44");
- airPlayMDNSProps.put("vv", "2");
- airPlayMDNSProps.put("model", "AppleTV3,2");
+ // Bit 27 off (legacy pairing); LO bits 0/4/8 restored — see AdvertisedReceiver.
+ airPlayMDNSProps.put("features", AdvertisedReceiver.FEATURES_TXT);
+ airPlayMDNSProps.put("srcvers", AdvertisedReceiver.SOURCE_VERSION);
+ airPlayMDNSProps.put("flags", AdvertisedReceiver.MDNS_FLAGS);
+ airPlayMDNSProps.put("vv", Integer.toString(AdvertisedReceiver.VV));
+ airPlayMDNSProps.put("model", AdvertisedReceiver.MODEL);
airPlayMDNSProps.put("rhd", "5.6.0.0");
airPlayMDNSProps.put("pw", "false");
airPlayMDNSProps.put("pk", "f3769a660475d27b4f6040381d784645e13e21c53e6d2da6a8c3d757086fc336");
@@ -88,8 +87,8 @@ private Map airTunesMDNSProps() {
airTunesMDNSProps.put("da", "true");
airTunesMDNSProps.put("et", "0,3,5");
airTunesMDNSProps.put("ek", "1");
- airTunesMDNSProps.put("ft", "0x527FFEE6,0x0");
- airTunesMDNSProps.put("am", "AppleTV3,2");
+ airTunesMDNSProps.put("ft", AdvertisedReceiver.FEATURES_TXT);
+ airTunesMDNSProps.put("am", AdvertisedReceiver.MODEL);
airTunesMDNSProps.put("md", "0,1,2");
airTunesMDNSProps.put("sr", "44100");
airTunesMDNSProps.put("ss", "16");
@@ -97,8 +96,8 @@ private Map airTunesMDNSProps() {
airTunesMDNSProps.put("sm", "false");
airTunesMDNSProps.put("tp", "UDP");
airTunesMDNSProps.put("txtvers", "1");
- airTunesMDNSProps.put("sf", "0x44");
- airTunesMDNSProps.put("vs", "220.68");
+ airTunesMDNSProps.put("sf", AdvertisedReceiver.MDNS_FLAGS);
+ airTunesMDNSProps.put("vs", AdvertisedReceiver.SOURCE_VERSION);
airTunesMDNSProps.put("vn", "65537");
airTunesMDNSProps.put("pk", "f3769a660475d27b4f6040381d784645e13e21c53e6d2da6a8c3d757086fc336");
return airTunesMDNSProps;
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/AudioServer.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/AudioServer.java
index d4ad96cf..8fda1eaa 100644
--- a/server/src/main/java/com/github/serezhka/airplay/server/internal/AudioServer.java
+++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/AudioServer.java
@@ -1,7 +1,7 @@
package com.github.serezhka.airplay.server.internal;
-import com.github.serezhka.airplay.lib.AirPlay;
-import com.github.serezhka.airplay.server.AirPlayConsumer;
+import com.github.serezhka.airplay.protocol.FairPlaySession;
+import com.github.serezhka.airplay.server.Playback;
import com.github.serezhka.airplay.server.internal.decoder.AudioDecoder;
import com.github.serezhka.airplay.server.internal.handler.audio.AudioHandler;
import io.netty.bootstrap.Bootstrap;
@@ -24,15 +24,15 @@
@RequiredArgsConstructor
public class AudioServer implements Runnable {
- private final AirPlay airPlay;
+ private final FairPlaySession airPlay;
private Thread thread;
- private AirPlayConsumer airPlayConsumer;
+ private Playback airPlayConsumer;
@Getter
private int port;
- public void start(AirPlayConsumer airPlayConsumer) throws InterruptedException {
+ public void start(Playback airPlayConsumer) throws InterruptedException {
this.airPlayConsumer = airPlayConsumer;
thread = new Thread(this);
thread.start();
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/ControlServer.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/ControlServer.java
index 239f611e..b03dabb4 100644
--- a/server/src/main/java/com/github/serezhka/airplay/server/internal/ControlServer.java
+++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/ControlServer.java
@@ -1,11 +1,10 @@
package com.github.serezhka.airplay.server.internal;
import com.github.serezhka.airplay.server.AirPlayConfig;
-import com.github.serezhka.airplay.server.AirPlayConsumer;
+import com.github.serezhka.airplay.server.Playback;
import com.github.serezhka.airplay.server.internal.handler.control.ControlHandler;
import com.github.serezhka.airplay.server.internal.handler.control.HlsFcupService;
import com.github.serezhka.airplay.server.internal.handler.session.SessionManager;
-import com.github.serezhka.airplay.lib.HlsLifecycle;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
@@ -35,18 +34,18 @@ public class ControlServer implements Runnable {
private final HlsFcupService hlsFcupService;
private final AirPlayConfig airPlayConfig;
- private final AirPlayConsumer airPlayConsumer;
+ private final Playback airPlayConsumer;
private Thread thread;
@Getter
private int port;
- public ControlServer(AirPlayConfig airPlayConfig, AirPlayConsumer airPlayConsumer) {
+ public ControlServer(AirPlayConfig airPlayConfig, Playback airPlayConsumer) {
this.airPlayConfig = airPlayConfig;
this.airPlayConsumer = airPlayConsumer;
this.hlsFcupService = new HlsFcupService(sessionManager, airPlayConsumer);
- HlsLifecycle.setOnEnded(hlsFcupService::refreshActivePlaylists);
+ airPlayConsumer.setObserver(hlsFcupService::refreshActivePlaylists);
}
public void start() throws InterruptedException {
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/VideoServer.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/VideoServer.java
index d9394097..f3462bc2 100644
--- a/server/src/main/java/com/github/serezhka/airplay/server/internal/VideoServer.java
+++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/VideoServer.java
@@ -1,7 +1,7 @@
package com.github.serezhka.airplay.server.internal;
-import com.github.serezhka.airplay.lib.AirPlay;
-import com.github.serezhka.airplay.server.AirPlayConsumer;
+import com.github.serezhka.airplay.protocol.FairPlaySession;
+import com.github.serezhka.airplay.server.Playback;
import com.github.serezhka.airplay.server.internal.decoder.VideoDecoder;
import com.github.serezhka.airplay.server.internal.handler.video.VideoHandler;
import io.netty.bootstrap.ServerBootstrap;
@@ -25,15 +25,15 @@
@RequiredArgsConstructor
public class VideoServer implements Runnable {
- private final AirPlay airPlay;
+ private final FairPlaySession airPlay;
private Thread thread;
- private AirPlayConsumer airPlayConsumer;
+ private Playback airPlayConsumer;
@Getter
private int port;
- public void start(AirPlayConsumer airPlayConsumer) throws InterruptedException {
+ public void start(Playback airPlayConsumer) throws InterruptedException {
this.airPlayConsumer = airPlayConsumer;
thread = new Thread(this);
thread.start();
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/audio/AudioHandler.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/audio/AudioHandler.java
index 1a8dd623..62ef2037 100644
--- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/audio/AudioHandler.java
+++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/audio/AudioHandler.java
@@ -1,7 +1,7 @@
package com.github.serezhka.airplay.server.internal.handler.audio;
-import com.github.serezhka.airplay.lib.AirPlay;
-import com.github.serezhka.airplay.server.AirPlayConsumer;
+import com.github.serezhka.airplay.protocol.FairPlaySession;
+import com.github.serezhka.airplay.server.Playback;
import com.github.serezhka.airplay.server.internal.packet.AudioPacket;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
@@ -14,8 +14,8 @@
@RequiredArgsConstructor
public class AudioHandler extends ChannelInboundHandlerAdapter {
- private final AirPlay airPlay;
- private final AirPlayConsumer dataConsumer;
+ private final FairPlaySession airPlay;
+ private final Playback dataConsumer;
private final AudioPacket[] buffer = new AudioPacket[512];
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java
index 6d53a01e..9277f6c6 100644
--- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java
+++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/ControlHandler.java
@@ -4,16 +4,17 @@
import com.dd.plist.NSData;
import com.dd.plist.NSDictionary;
import com.dd.plist.PropertyListParser;
-import com.github.serezhka.airplay.lib.AudioStreamInfo;
-import com.github.serezhka.airplay.lib.VideoStreamInfo;
+import com.github.serezhka.airplay.protocol.media.AudioStreamInfo;
+import com.github.serezhka.airplay.protocol.media.VideoStreamInfo;
import com.github.serezhka.airplay.server.AirPlayConfig;
-import com.github.serezhka.airplay.server.AirPlayConsumer;
+import com.github.serezhka.airplay.server.Playback;
import com.github.serezhka.airplay.server.ControlExchange;
import com.github.serezhka.airplay.server.internal.handler.session.HlsPlaylistState;
import com.github.serezhka.airplay.server.internal.handler.session.HlsUriRewrite;
import com.github.serezhka.airplay.server.internal.handler.session.PlaylistRequest;
import com.github.serezhka.airplay.server.internal.handler.session.Session;
import com.github.serezhka.airplay.server.internal.handler.session.SessionManager;
+import com.github.serezhka.airplay.server.internal.handler.util.AirPlayVolume;
import com.github.serezhka.airplay.server.internal.handler.util.PropertyListUtil;
import io.lindstrom.m3u8.model.*;
import io.lindstrom.m3u8.parser.MediaPlaylistParser;
@@ -35,29 +36,66 @@
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Slf4j
public class ControlHandler extends ChannelInboundHandlerAdapter {
+ /** If video mediadata never arrives, start anyway so /play does not hang. */
+ private static final long PLAYBACK_START_FALLBACK_MS = 8_000;
+
private final SessionManager sessionManager;
private final HlsFcupService hlsFcupService;
private final AirPlayConfig airPlayConfig;
- private final AirPlayConsumer airPlayConsumer;
+ private final Playback airPlayConsumer;
+ private final ScheduledExecutorService hlsScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "airplay-hls-scheduler");
+ t.setDaemon(true);
+ return t;
+ });
+ private final ConcurrentHashMap> pendingPlaybackStarts = new ConcurrentHashMap<>();
public ControlHandler(SessionManager sessionManager,
HlsFcupService hlsFcupService,
AirPlayConfig airPlayConfig,
- AirPlayConsumer airPlayConsumer) {
+ Playback airPlayConsumer) {
this.sessionManager = sessionManager;
this.hlsFcupService = hlsFcupService;
this.airPlayConfig = airPlayConfig;
this.airPlayConsumer = airPlayConsumer;
}
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+ for (Session session : sessionManager.allSessions()) {
+ var reverse = session.getReverseContexts();
+ ChannelHandlerContext eventCtx = reverse.get("event");
+ if (eventCtx == ctx) {
+ log.info("Reverse event channel closed, stopping HLS session {}", session.getId());
+ stopHlsPlayback(session);
+ }
+ reverse.entrySet().removeIf(entry -> entry.getValue() == ctx);
+ }
+ super.channelInactive(ctx);
+ }
+
+ private void stopHlsPlayback(Session session) {
+ cancelPlaybackStartFallback(session.getId());
+ hlsFcupService.cancelAllMasterPolls();
+ hlsFcupService.clearReverseQueue(session);
+ session.setHlsPlaylistState(null);
+ airPlayConsumer.onPlaylistRemoved();
+ }
+
private byte[] pendingRequestBody = new byte[0];
@Override
@@ -72,6 +110,7 @@ public final void channelRead(ChannelHandlerContext ctx, Object msg) throws Exce
}
} else if (msg instanceof FullHttpResponse response) {
log.debug("Reverse channel response: {} {}", response.status(), response.content().readableBytes());
+ hlsFcupService.onReverseEventResponse(ctx);
} else {
log.error("Unknown control message type: {}", msg);
}
@@ -135,9 +174,10 @@ private void dispatchControlRequest(ChannelHandlerContext ctx, FullHttpRequest r
} else if (HttpMethod.POST.equals(request.method()) && decoder.path().equals("/getProperty")) {
handleGetProperty(ctx, request);
} else if (HttpMethod.POST.equals(request.method()) && decoder.path().equals("/scrub")) {
- log.info(request.uri()); // TODO
- } else if (HttpMethod.POST.equals(request.method()) && decoder.path().equals("/stop")) {
- log.info(request.uri()); // TODO
+ handleScrub(ctx, request);
+ } else if (HttpMethod.POST.equals(request.method())
+ && (decoder.path().equals("/stop") || decoder.path().equals("/stop2"))) {
+ handleStop(ctx, request);
} else if (HttpMethod.GET.equals(request.method()) && decoder.path().startsWith("/playlist")) {
handleGetPlaylist(ctx, request);
} else {
@@ -229,8 +269,7 @@ private void handleRtspFeedback(ChannelHandlerContext ctx, FullHttpRequest reque
}
private void handleRtspGetParameter(ChannelHandlerContext ctx, FullHttpRequest request) {
- // TODO get requested param and respond accordingly
- byte[] content = "volume: 0.000000\r\n".getBytes(StandardCharsets.US_ASCII);
+ byte[] content = AirPlayVolume.formatRtspParameter(airPlayConsumer.volume()).getBytes(StandardCharsets.US_ASCII);
var response = createRtspResponse(request);
response.content().writeBytes(content);
sendResponse(ctx, request, response);
@@ -244,7 +283,22 @@ private void handleRtspRecord(ChannelHandlerContext ctx, FullHttpRequest request
}
private void handleRtspSetParameter(ChannelHandlerContext ctx, FullHttpRequest request) {
- // TODO get requested param and respond accordingly
+ var contentType = Optional.ofNullable(request.headers().get(HttpHeaderNames.CONTENT_TYPE)).orElse("");
+ if (contentType.contains("text/parameters") && request.content().isReadable()) {
+ String body = request.content().toString(StandardCharsets.US_ASCII);
+ for (String line : body.split("\r?\n")) {
+ if (line.regionMatches(true, 0, "volume:", 0, 7)) {
+ try {
+ double db = Double.parseDouble(line.substring(7).trim());
+ double linear = AirPlayVolume.fromDecibels(db);
+ airPlayConsumer.onVolume(linear);
+ log.info("RTSP volume {} dB -> linear {}", db, linear);
+ } catch (NumberFormatException e) {
+ log.warn("Invalid RTSP volume line: {}", line);
+ }
+ }
+ }
+ }
var response = createRtspResponse(request);
response.headers().add("Audio-Jack-Status", "connected; type=analog");
sendResponse(ctx, request, response);
@@ -276,6 +330,11 @@ private void handleRtspTeardown(ChannelHandlerContext ctx, FullHttpRequest reque
session.getAudioServer().stop();
session.getAudioControlServer().stop();
session.getVideoServer().stop();
+ // Full session teardown only — stream-specific TEARDOWN must not stop HLS.
+ if (session.getHlsPlaylistState() != null) {
+ log.info("RTSP TEARDOWN (full): stopping HLS session {}", session.getId());
+ stopHlsPlayback(session);
+ }
}
var response = createRtspResponse(request);
sendResponse(ctx, request, response);
@@ -314,6 +373,24 @@ private void handlePlay(ChannelHandlerContext ctx, FullHttpRequest request) thro
log.debug("POST /play body:\n{}", play.toXMLPropertyList());
}
+ if (play.get("volume") != null) {
+ try {
+ double linear = AirPlayVolume.clampLinear(play.get("volume").toJavaObject(Double.class));
+ airPlayConsumer.onVolume(linear);
+ } catch (Exception e) {
+ log.debug("Ignoring /play volume", e);
+ }
+ }
+
+ Double startPositionSeconds = null;
+ if (play.get("Start-Position-Seconds") != null) {
+ try {
+ startPositionSeconds = play.get("Start-Position-Seconds").toJavaObject(Double.class);
+ } catch (Exception e) {
+ log.debug("Ignoring Start-Position-Seconds", e);
+ }
+ }
+
var clientProcName = play.get("clientProcName") != null
? play.get("clientProcName").toJavaObject(String.class)
: "";
@@ -321,21 +398,9 @@ private void handlePlay(ChannelHandlerContext ctx, FullHttpRequest request) thro
? play.get("Content-Location").toJavaObject(String.class)
: null;
if (playlistUri != null && !playlistUri.isBlank()) {
- var session = resolveSession(request);
- var playlistUriLocal = playlistUriToLocal(playlistUri, playlistBaseUrl(ctx), session.getId());
- var remotePlaylistUri = playlistUri.split("\\?")[0];
-
var response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
sendResponse(ctx, request, response);
-
- if (remotePlaylistUri.contains("master.m3u8")) {
- var hls = new HlsPlaylistState(remotePlaylistUri, playlistUriLocal);
- session.setHlsPlaylistState(hls);
- log.info("HLS play from [{}]: prefetching playlists via FCUP, localUri={}", clientProcName, playlistUriLocal);
- hlsFcupService.sendFcupRequest(session, remotePlaylistUri);
- } else {
- airPlayConsumer.onMediaPlaylist(playlistUriLocal);
- }
+ startMediaPlaylist(ctx, resolveSession(request), playlistUri, clientProcName, startPositionSeconds);
} else {
log.error("Client proc name [{}] has no Content-Location playlist", clientProcName);
var response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NOT_IMPLEMENTED);
@@ -343,12 +408,70 @@ private void handlePlay(ChannelHandlerContext ctx, FullHttpRequest request) thro
}
}
+ /**
+ * Start (or restart) media/HLS playback from a Content-Location URI.
+ * Used by POST /play and by playlistInsert (e.g. quality switch).
+ */
+ private void startMediaPlaylist(ChannelHandlerContext ctx, Session session, String playlistUri,
+ String clientProcName, Double startPositionSeconds) {
+ // Clients often keep the mirror stream up when starting YouTube HLS; drop it so we
+ // do not keep rendering a stale mirrored UI beside the media player.
+ stopMirrorVideoIfRunning(session);
+
+ var playlistUriLocal = playlistUriToLocal(playlistUri, playlistBaseUrl(ctx), session.getId());
+ var remotePlaylistUri = playlistUri.split("\\?")[0];
+
+ if (remotePlaylistUri.contains("master.m3u8")) {
+ hlsFcupService.cancelAllMasterPolls();
+ var hls = new HlsPlaylistState(remotePlaylistUri, playlistUriLocal);
+ hls.setPlaybackRate(1);
+ session.setHlsPlaylistState(hls);
+ if (startPositionSeconds != null && startPositionSeconds > 0) {
+ hls.setPendingSeekSeconds(startPositionSeconds);
+ }
+ log.info("HLS play from [{}]: prefetching playlists via FCUP, localUri={}", clientProcName, playlistUriLocal);
+ hlsFcupService.sendFcupRequest(session, remotePlaylistUri);
+ } else {
+ airPlayConsumer.onPlaylist(playlistUriLocal);
+ if (startPositionSeconds != null && startPositionSeconds > 0) {
+ airPlayConsumer.onSeek(startPositionSeconds);
+ }
+ }
+ }
+
+ private void stopMirrorVideoIfRunning(Session session) {
+ try {
+ log.info("Stopping screen-mirror video before HLS session {}", session.getId());
+ airPlayConsumer.onVideoSrcDisconnect();
+ session.getVideoServer().stop();
+ } catch (Exception e) {
+ log.debug("Mirror video stop before HLS ignored: {}", e.toString());
+ }
+ }
+
private void handleSetProperty(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception {
var decoder = new QueryStringDecoder(request.uri());
- log.debug("SET_PARAMETER path={}, params={}", decoder.path(), decoder.parameters());
+ var path = decoder.path();
+ var params = decoder.parameters();
var play = (NSDictionary) BinaryPropertyListParser.parse(new ByteBufInputStream(request.content()));
- if (log.isDebugEnabled()) {
- log.debug("SET_PARAMETER body:\n{}", play.toXMLPropertyList());
+ boolean isActionAtItemEnd = params.containsKey("actionAtItemEnd") || request.uri().contains("actionAtItemEnd");
+ boolean isSelectedMedia = params.containsKey("selectedMediaArray") || request.uri().contains("selectedMediaArray");
+ if (isActionAtItemEnd || isSelectedMedia) {
+ log.info("SET_PROPERTY {}: {}", request.uri(), play.toXMLPropertyList().replaceAll("\\s+", " ").trim());
+ } else {
+ log.debug("SET_PARAMETER path={}, params={}", path, params);
+ if (log.isDebugEnabled()) {
+ log.debug("SET_PARAMETER body:\n{}", play.toXMLPropertyList());
+ }
+ }
+ if (isActionAtItemEnd && play.get("value") != null) {
+ var session = resolveSession(request);
+ var hls = session.getHlsPlaylistState();
+ if (hls != null) {
+ int action = ((Number) play.get("value").toJavaObject()).intValue();
+ hls.setActionAtItemEnd(action);
+ log.info("actionAtItemEnd={} session={}", action, session.getId());
+ }
}
var response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
@@ -357,23 +480,146 @@ private void handleSetProperty(ChannelHandlerContext ctx, FullHttpRequest reques
private void handleRate(ChannelHandlerContext ctx, FullHttpRequest request) {
var decoder = new QueryStringDecoder(request.uri());
- var rate = (int) Double.parseDouble(decoder.parameters().get("value").get(0));
+ double value = Double.parseDouble(decoder.parameters().get("value").get(0));
+ var session = resolveSession(request);
+ var hls = session.getHlsPlaylistState();
+ log.info("POST /rate value={}", value);
+
+ // After VOD ad EOS we wait for the next /play; YouTube still probes rate 0/1.
+ // Answering with paused→playing makes it think the ad is still active.
+ if (hls != null && hls.isWaitingForMasterChange()) {
+ log.info("Ignoring rate={} while waiting for next HLS item session={}", value, session.getId());
+ var response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
+ sendResponse(ctx, request, response);
+ return;
+ }
+
+ if (value == 0) {
+ if (hls != null && hls.shouldIgnorePause()) {
+ // /scrub arms a latch: drop rate=0 until the matching rate=1 (no timer).
+ log.info("Ignoring rate=0 until rate=1 after scrub session={}", session.getId());
+ } else {
+ if (hls != null) {
+ hls.setPlaybackRate(0);
+ }
+ airPlayConsumer.onPause();
+ hlsFcupService.sendPlaybackStateEvent(session, "paused");
+ }
+ } else {
+ boolean wasPaused = hls != null && hls.getPlaybackRate() <= 0;
+ if (hls != null) {
+ hls.clearScrubIgnorePause();
+ hls.setPlaybackRate(1);
+ }
+ // Only resume when leaving pause — rate=1 while already playing must not re-seek.
+ if (wasPaused) {
+ airPlayConsumer.onResume();
+ }
+ hlsFcupService.sendPlaybackStateEvent(session, "playing");
+ }
- if (rate == 0) {
- airPlayConsumer.onMediaPlaylistPause();
+ var response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
+ sendResponse(ctx, request, response);
+ }
+
+ private void handleScrub(ChannelHandlerContext ctx, FullHttpRequest request) {
+ var decoder = new QueryStringDecoder(request.uri());
+ var positions = decoder.parameters().get("position");
+ if (positions != null && !positions.isEmpty()) {
+ try {
+ double position = Double.parseDouble(positions.get(0));
+ log.info("POST /scrub position={}", position);
+ var session = resolveSession(request);
+ var hls = session.getHlsPlaylistState();
+ if (hls != null) {
+ // Latch: post-scrub rate=0 is bracket noise until rate=1.
+ hls.markScrubIgnorePauseUntilPlay();
+ hls.setPlaybackRate(1);
+ }
+ if (hls != null && !hls.isPlaybackStarted()) {
+ hls.setPendingSeekSeconds(position);
+ log.info("Deferring /scrub to pending seek until HLS starts");
+ } else {
+ airPlayConsumer.onSeek(position);
+ hlsFcupService.sendPlaybackStateEvent(session, "playing");
+ }
+ } catch (NumberFormatException e) {
+ log.warn("Invalid /scrub position: {}", positions.get(0));
+ }
} else {
- airPlayConsumer.onMediaPlaylistResume();
+ log.warn("POST /scrub without position: {}", request.uri());
}
+ var response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
+ sendResponse(ctx, request, response);
+ }
+ private void handleStop(ChannelHandlerContext ctx, FullHttpRequest request) {
+ var path = new QueryStringDecoder(request.uri()).path();
+ log.info("POST {}", path);
+ stopHlsPlayback(resolveSession(request));
var response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
sendResponse(ctx, request, response);
}
private void handlePlaybackInfo(ChannelHandlerContext ctx, FullHttpRequest request) {
+ var session = resolveSession(request);
+ var hls = session.getHlsPlaylistState();
+ var fromPlayer = airPlayConsumer.info();
+ double duration = fromPlayer.duration();
+ double position = fromPlayer.position();
+ double rate = fromPlayer.rate();
+ var override = PlaybackInfoOverride.get();
+ if (override != null) {
+ duration = override.duration();
+ position = override.position();
+ rate = override.rate();
+ log.info("Playback-info override duration={} position={} rate={}", duration, position, rate);
+ } else if (hls != null && hls.isWaitingForMasterChange() && hls.isLivePlaylist()) {
+ // Live post-EOS gap: duration=0 → buffer-empty / not ready (loading).
+ duration = 0;
+ position = 0;
+ rate = 0;
+ } else if (hls != null) {
+ // Live / sliding windows: duration unknown. Finite ENDLIST VOD is authoritative.
+ // Consumer clocks alone can report multi-hour values for short ads.
+ if (hls.isLivePlaylist()) {
+ duration = 0;
+ } else {
+ double playlistDur = hls.getMediaDurationSeconds();
+ if (playlistDur > 0) {
+ duration = playlistDur;
+ } else if (duration > 600) {
+ duration = 0;
+ }
+ }
+ if (hls.isWaitingForMasterChange() && duration > 0) {
+ // VOD ad finished: pin clock at end. Keep rate=1 (dump 20260916-164913
+ // advanced with duration>0 rate=1). rate=0 here looks like a user pause and
+ // blocks playlistRemove until Skip. Never emit "paused" / duration=0.
+ // Prefer the player/EOS length when session mediaDuration was inflated by a
+ // later FCUP body (seen: 7.04s ad reported as 15.6 → no playlistRemove).
+ double playerDur = fromPlayer.duration();
+ if (playerDur > 0.5 && playerDur + 0.25 < duration) {
+ duration = playerDur;
+ }
+ position = duration;
+ rate = 1;
+ } else {
+ if (duration > 0) {
+ position = Math.min(position, duration);
+ }
+ // Prefer session rate from POST /rate. Player rate stays 0 while MSE/hls.js
+ // buffers — OR-ing it forced rate=0 with duration=6 at t≈0 (dump
+ // 20260918-080456) → phone pause UI → rate 1/0 fight → debounced "paused".
+ rate = hls.getPlaybackRate() <= 0 ? 0 : 1;
+ }
+ } else if (duration > 600) {
+ duration = 0;
+ }
+ var playbackInfo = new Playback.Info(duration, position, rate);
var response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
response.headers().add(HttpHeaderNames.CONTENT_TYPE, "text/x-apple-plist+xml");
- var playbackInfo = PropertyListUtil.preparePlaybackInfoResponse(airPlayConsumer.playbackInfo());
- response.content().writeBytes(playbackInfo);
+ response.content().writeBytes(PropertyListUtil.preparePlaybackInfoResponse(playbackInfo));
sendResponse(ctx, request, response);
}
@@ -388,33 +634,41 @@ private void handleAction(ChannelHandlerContext ctx, FullHttpRequest request) th
if ("unhandledURLResponse".equals(type)) {
handleUnhandledUrlResponse(ctx, request, action);
} else if ("playlistRemove".equals(type)) {
- /*
-
- type
- playlistRemove
- params
-
- item
-
- uuid
- 59F93E62-4E79-4A8F-A55A-D7DA65247AF1
-
-
-
- */
- airPlayConsumer.onMediaPlaylistRemove();
- hlsFcupService.cancelAllMasterPolls();
+ stopHlsPlayback(resolveSession(request));
+ } else if ("playlistInsert".equals(type)) {
+ handlePlaylistInsert(ctx, request, action);
}
var response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
sendResponse(ctx, request, response);
}
+ private void handlePlaylistInsert(ChannelHandlerContext ctx, FullHttpRequest request, NSDictionary action) {
+ var params = action.get("params") instanceof NSDictionary dictionary ? dictionary : null;
+ var item = params != null && params.get("item") instanceof NSDictionary dictionary ? dictionary : null;
+ if (item == null || item.get("Content-Location") == null) {
+ log.warn("playlistInsert without Content-Location: {}", action.toXMLPropertyList());
+ return;
+ }
+ var playlistUri = item.get("Content-Location").toJavaObject(String.class);
+ var clientProcName = item.get("clientProcName") != null
+ ? item.get("clientProcName").toJavaObject(String.class)
+ : "";
+ log.info("playlistInsert Content-Location={} from [{}]", playlistUri, clientProcName);
+ startMediaPlaylist(ctx, resolveSession(request), playlistUri, clientProcName, null);
+ }
+
private void handleGetProperty(ChannelHandlerContext ctx, FullHttpRequest request) {
- // TODO get requested param and respond accordingly
var decoder = new QueryStringDecoder(request.uri());
- log.debug("GET_PARAMETER path={}, params={}", decoder.path(), decoder.parameters());
+ // YouTube probes playbackAccessLog / playbackErrorLog after VOD EOS (prelude to
+ // playlistRemove in good dumps). Log the property name; body stays empty until we
+ // have a verified AccessLog template from a working receiver.
+ log.info("POST /getProperty {}", decoder.parameters().keySet());
var response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
+ response.headers().add(HttpHeaderNames.CONTENT_TYPE, "text/x-apple-plist+xml");
+ byte[] body = PropertyListUtil.prepareEmptyPropertyResponse();
+ response.content().writeBytes(body);
+ HttpUtil.setContentLength(response, body.length);
sendResponse(ctx, request, response);
}
@@ -434,12 +688,12 @@ private void handleUnhandledUrlResponse(ChannelHandlerContext ctx, FullHttpReque
log.warn("FCUP response is missing URL: {}", action.toXMLPropertyList());
return;
}
- var fcupResponseURL = params.get("FCUP_Response_URL").toJavaObject(String.class);
+ var fcupResponseURL = normalizeFcupUrl(params.get("FCUP_Response_URL").toJavaObject(String.class));
var session = resolveSession(request);
if (params.get("FCUP_Response_Data") == null) {
log.warn("FCUP response without data: url={}, session={}", fcupResponseURL, session.getId());
var hls = session.getHlsPlaylistState();
- if (hls != null) {
+ if (hls != null && !hls.isWaitingForMasterChange()) {
continueMediaPrefetch(session, hls);
}
return;
@@ -449,9 +703,12 @@ private void handleUnhandledUrlResponse(ChannelHandlerContext ctx, FullHttpReque
log.info("FCUP response: url={}, bytes={}, session={}", fcupResponseURL, fcupResponse.length(), session.getId());
String body;
+ List remoteMediaUris = null;
try {
if (fcupResponseURL.contains("master.m3u8")) {
- body = masterPlaylistToLocalUrls(fcupResponse, playlistBaseUrl(ctx), session.getId());
+ var rewritten = rewriteMasterPlaylist(fcupResponse, playlistBaseUrl(ctx), session.getId());
+ body = rewritten.localBody();
+ remoteMediaUris = rewritten.remoteMediaUris();
} else if (fcupResponseURL.contains("mediadata.m3u8")) {
body = mediaPlaylistBody(fcupResponse);
} else {
@@ -467,38 +724,99 @@ private void handleUnhandledUrlResponse(ChannelHandlerContext ctx, FullHttpReque
try {
if (fcupResponseURL.contains("master.m3u8")) {
if (hls.isPlaybackStarted()) {
- hlsFcupService.onMasterRefreshDuringPlayback(session, body);
+ hlsFcupService.onMasterRefreshDuringPlayback(session, body, fcupResponse, remoteMediaUris);
} else {
- hls.storeMasterPlaylist(body, fcupResponse);
- log.info("HLS master received: {} media playlists queued, state={}", hls.pendingMediaUriCount(), hls);
+ hls.storeMasterPlaylist(body, remoteMediaUris);
+ hls.recordRawMasterIfChanged(fcupResponse);
+ log.info("HLS master received: {} media playlists queued ({} video), state={}",
+ hls.pendingMediaUriCount(), hls.videoMediaUriCount(), hls);
+ // Wait for at least one video mediadata before starting the player.
+ // Starting on master alone (48 audio-language alts first) yields black
+ // screen + wall-clock EOS while GST still has no fragments.
if (!hls.isPlaybackStarted()) {
- hls.markPlaybackStarted();
- log.info("HLS starting playback after master: {}", hls.getPlaylistUriLocal());
- airPlayConsumer.onMediaPlaylist(hls.getPlaylistUriLocal());
+ schedulePlaybackStartFallback(session, hls);
}
}
} else {
hls.putPlaylist(fcupResponseURL, body);
+ maybeStartHlsPlayback(session, hls, "mediadata ready");
}
} catch (Exception e) {
log.warn("Failed to update HLS state for {}", fcupResponseURL, e);
hls.putPlaylist(fcupResponseURL, body);
+ maybeStartHlsPlayback(session, hls, "mediadata ready after error");
}
- airPlayConsumer.onMediaPlaylistContent(fcupResponseURL, body);
+ airPlayConsumer.onPlaylistContent(fcupResponseURL, body);
replyPendingPlaylists(session, fcupResponseURL, body);
- continueMediaPrefetch(session, hls);
+ if (hls.isPostEosMediaRefreshing()) {
+ // Only advance on mediadata responses — master handler already queued the first URI.
+ if (fcupResponseURL.contains("mediadata.m3u8")) {
+ if (hls.hasMoreMediaUris()) {
+ hlsFcupService.sendFcupRequest(session, hls.nextMediaUri());
+ } else {
+ hlsFcupService.onPostEosMediaRefreshComplete(session);
+ }
+ }
+ } else if (!hls.isWaitingForMasterChange()) {
+ continueMediaPrefetch(session, hls);
+ }
return;
}
if (replyPendingPlaylists(session, fcupResponseURL, body)) {
- airPlayConsumer.onMediaPlaylistContent(fcupResponseURL, body);
+ airPlayConsumer.onPlaylistContent(fcupResponseURL, body);
} else {
log.warn("No pending GET /playlist for {}", fcupResponseURL);
}
}
+ private void startHlsPlayback(Session session, HlsPlaylistState hls) {
+ airPlayConsumer.onPlaylist(hls.getPlaylistUriLocal());
+ hlsFcupService.sendPlaybackStateEvent(session, "playing");
+ Double seek = hls.takePendingSeekSeconds();
+ if (seek != null && seek > 0) {
+ airPlayConsumer.onSeek(seek);
+ }
+ }
+
+ private void maybeStartHlsPlayback(Session session, HlsPlaylistState hls, String reason) {
+ if (hls == null || hls.isPlaybackStarted() || !hls.hasCachedVideoMedia()) {
+ return;
+ }
+ hls.markPlaybackStarted();
+ cancelPlaybackStartFallback(session.getId());
+ log.info("HLS starting playback ({}): {}", reason, hls.getPlaylistUriLocal());
+ startHlsPlayback(session, hls);
+ }
+
+ private void schedulePlaybackStartFallback(Session session, HlsPlaylistState hls) {
+ cancelPlaybackStartFallback(session.getId());
+ String sessionId = session.getId();
+ ScheduledFuture> future = hlsScheduler.schedule(() -> {
+ pendingPlaybackStarts.remove(sessionId);
+ if (hls.isPlaybackStarted()) {
+ return;
+ }
+ if (session.getHlsPlaylistState() != hls) {
+ return;
+ }
+ hls.markPlaybackStarted();
+ log.info("HLS starting playback (fallback after {}ms): {}",
+ PLAYBACK_START_FALLBACK_MS, hls.getPlaylistUriLocal());
+ startHlsPlayback(session, hls);
+ }, PLAYBACK_START_FALLBACK_MS, TimeUnit.MILLISECONDS);
+ pendingPlaybackStarts.put(sessionId, future);
+ }
+
+ private void cancelPlaybackStartFallback(String sessionId) {
+ ScheduledFuture> future = pendingPlaybackStarts.remove(sessionId);
+ if (future != null) {
+ future.cancel(false);
+ }
+ }
+
private void continueMediaPrefetch(Session session, HlsPlaylistState hls) {
if (hls.hasMoreMediaUris()) {
hlsFcupService.sendFcupRequest(session, hls.nextMediaUri());
@@ -586,24 +904,26 @@ private void handleGetPlaylist(ChannelHandlerContext ctx, FullHttpRequest reques
var hls = session.getHlsPlaylistState();
var cached = hls != null ? hls.getPlaylist(playlistUriRemote) : null;
- boolean isMediaPlaylist = playlistUriRemote.contains("mediadata.m3u8");
boolean isMasterPlaylist = playlistUriRemote.contains("master.m3u8");
- boolean refreshPlaylist = isMediaPlaylist || (isMasterPlaylist && hls != null && hls.isPlaybackStarted());
+ // Prefer cache so the consumer is not blocked; re-FCUP in the background so live
+ // mediadata keeps growing (stale cache freezes livestreams after the first window).
+ boolean refreshPlaylist = isMasterPlaylist && hls != null && hls.isPlaybackStarted() && cached == null;
- if (cached != null && !refreshPlaylist) {
+ if (cached != null) {
log.info("Serving cached playlist {}", playlistUriRemote);
replyPlaylist(pending, cached);
+ if (hls != null && hls.isPlaybackStarted()
+ && hls.shouldRefreshPlaylist(playlistUriRemote, TimeUnit.MILLISECONDS.toNanos(1500))) {
+ hlsFcupService.sendFcupRequest(session, playlistUriRemote);
+ }
return;
}
session.enqueuePlaylistRequest(playlistUriRemote, pending);
recordPendingPlaylistRequest(pending);
- if (refreshPlaylist) {
- log.info("GET /playlist {} requesting FCUP refresh", playlistUriRemote);
- hlsFcupService.sendFcupRequest(session, playlistUriRemote);
- } else if (hls == null) {
- log.info("GET /playlist {} without HLS prefetch, requesting FCUP", playlistUriRemote);
+ if (cached == null && (refreshPlaylist || hls == null || !hls.hasMoreMediaUris())) {
+ log.info("GET /playlist {} requesting FCUP", playlistUriRemote);
hlsFcupService.sendFcupRequest(session, playlistUriRemote);
} else {
log.info("GET /playlist {} waiting for HLS prefetch ({})", playlistUriRemote, hls);
@@ -626,11 +946,49 @@ private String playlistPathToRemote(String playlistPath) {
private String playlistBaseUrl(ChannelHandlerContext ctx) {
var port = ((ServerSocketChannel) ctx.channel().parent()).localAddress().getPort();
- return String.format("http://localhost:%s/playlist", port);
+ // Use IPv4 loopback: on Windows "localhost" often resolves to ::1 while the
+ // control server listens on IPv4, so the local HLS consumer cannot fetch playlists.
+ return String.format("http://127.0.0.1:%s/playlist", port);
}
private String masterPlaylistToLocalUrls(String masterPlaylist, String baseUrl, String sessionId) {
- return HlsUriRewrite.rewritePlaylist(masterPlaylist, baseUrl, sessionId);
+ return rewriteMasterPlaylist(masterPlaylist, baseUrl, sessionId).localBody();
+ }
+
+ private record MasterRewrite(String localBody, List remoteMediaUris) {
+ }
+
+ /**
+ * Prefer AVC on the remote (mlhls) master so FCUP URIs stay mlhls://…,
+ * then rewrite those URIs to local http for the media consumer.
+ */
+ private MasterRewrite rewriteMasterPlaylist(String masterPlaylist, String baseUrl, String sessionId) {
+ String filteredRemote = HlsUriRewrite.preferAvcVariants(masterPlaylist);
+ if (!filteredRemote.equals(masterPlaylist)) {
+ log.info("Filtered HLS master to AVC-only variants (drop VP9/AV1/subtitles)");
+ }
+ List remoteMediaUris;
+ try {
+ remoteMediaUris = HlsUriRewrite.extractMediaUris(filteredRemote);
+ } catch (PlaylistParserException e) {
+ log.warn("Failed to extract media URIs from filtered master", e);
+ remoteMediaUris = List.of();
+ }
+ String localBody = HlsUriRewrite.rewritePlaylist(filteredRemote, baseUrl, sessionId);
+ return new MasterRewrite(localBody, remoteMediaUris);
+ }
+
+ /** Map accidental local playlist URLs back to mlhls:// for cache / pending-request keys. */
+ private static String normalizeFcupUrl(String url) {
+ if (url == null) {
+ return null;
+ }
+ String bare = url.split("\\?")[0];
+ int playlistIdx = bare.indexOf("/playlist/");
+ if (bare.startsWith("http") && playlistIdx >= 0) {
+ return "mlhls://localhost" + bare.substring(playlistIdx + "/playlist".length());
+ }
+ return bare;
}
private DefaultFullHttpResponse createRtspResponse(FullHttpRequest request) {
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupService.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupService.java
index 7f83cf4a..9ca08b8f 100644
--- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupService.java
+++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupService.java
@@ -1,16 +1,20 @@
package com.github.serezhka.airplay.server.internal.handler.control;
-import com.github.serezhka.airplay.server.AirPlayConsumer;
+import com.github.serezhka.airplay.server.Playback;
import com.github.serezhka.airplay.server.internal.handler.session.Session;
import com.github.serezhka.airplay.server.internal.handler.session.SessionManager;
import com.github.serezhka.airplay.server.internal.handler.util.PropertyListUtil;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.handler.codec.http.DefaultFullHttpRequest;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.HttpVersion;
-import io.netty.handler.codec.http.DefaultFullHttpRequest;
import lombok.extern.slf4j.Slf4j;
+import java.util.ArrayDeque;
+import java.util.List;
import java.util.Map;
+import java.util.Queue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
@@ -21,19 +25,28 @@
public class HlsFcupService {
private static final long MASTER_POLL_INTERVAL_MS = 2000;
+ /**
+ * After the first post-EOS mediadata sweep, re-FCUP all mediadata every N master polls.
+ * YouTube ad→content often keeps the same master URI list and only swaps VOD bodies.
+ * N=3 (~6s) balances freshness vs reverse /event channel load (playlistRemove / next /play).
+ */
+ private static final int MEDIA_RESWEEP_EVERY_MASTER_POLLS = 3;
private final SessionManager sessionManager;
- private final AirPlayConsumer airPlayConsumer;
+ private final Playback airPlayConsumer;
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread thread = new Thread(r, "airplay-hls-master-poll");
thread.setDaemon(true);
return thread;
});
private final Map> masterPollTasks = new ConcurrentHashMap<>();
+ /** One in-flight reverse {@code POST /event} per session (no HTTP pipelining). */
+ private final Map reverseQueues = new ConcurrentHashMap<>();
- public HlsFcupService(SessionManager sessionManager, AirPlayConsumer airPlayConsumer) {
+ public HlsFcupService(SessionManager sessionManager, Playback airPlayConsumer) {
this.sessionManager = sessionManager;
this.airPlayConsumer = airPlayConsumer;
+ startSignalFileWatcher();
}
public void refreshActivePlaylists() {
@@ -42,32 +55,178 @@ public void refreshActivePlaylists() {
if (hls == null || !hls.isPlaybackStarted()) {
continue;
}
- log.info("HLS segment ended, polling for master change session {}", session.getId());
+ cancelMasterPoll(session.getId());
+ hls.abortMediaPrefetch();
+ hls.setWaitingForMasterChange(false);
+ hls.setPlaybackRate(0);
+
+ // Live sliding windows: loading + master/media FCUP until body swap.
+ // VOD ENDLIST: push the known-good reverse /event EOS burst (itemPlayedToEnd →
+ // stopped/reason=ended → itemRemoved → currentItemChanged), keep rate=1 pin.
+ // Lone paused/stopped without that burst hung historically; do not FCUP-storm VOD.
hls.setWaitingForMasterChange(true);
- hls.invalidatePlaylists();
- requestMasterRefresh(session);
+ hls.resetPostEosMediaSweep();
+ if (hls.isLivePlaylist()) {
+ log.info("HLS ended (live playlist), refreshing master (keep session) {}", session.getId());
+ sendPlaybackStateEvent(session, "loading");
+ requestMasterRefresh(session);
+ scheduleMasterPoll(session);
+ } else {
+ log.info("HLS ended (VOD actionAtItemEnd={}), emit EOS reverse-event burst session={}",
+ hls.getActionAtItemEnd(), session.getId());
+ hls.setPlaybackRate(1);
+ PlaybackInfoOverride.clear();
+ sendVodEosEventBurst(session);
+ }
+ }
+ }
+
+ /** Poll {@code /tmp/airplay-hls-signal} for manual reverse/FCUP/playback-info experiments. */
+ private void startSignalFileWatcher() {
+ scheduler.scheduleAtFixedRate(this::drainSignalFile, 500, 250, TimeUnit.MILLISECONDS);
+ }
+
+ private void drainSignalFile() {
+ java.nio.file.Path path = java.nio.file.Path.of("/tmp/airplay-hls-signal");
+ if (!java.nio.file.Files.isRegularFile(path)) {
+ return;
+ }
+ try {
+ String raw = java.nio.file.Files.readString(path).trim();
+ java.nio.file.Files.deleteIfExists(path);
+ if (raw.isEmpty()) {
+ return;
+ }
+ for (String line : raw.split("\\R")) {
+ applySignal(line.trim());
+ }
+ } catch (Exception e) {
+ log.warn("airplay-hls-signal: {}", e.toString());
+ }
+ }
+
+ private void applySignal(String line) {
+ if (line.isEmpty() || line.startsWith("#")) {
+ return;
+ }
+ log.warn("HLS debug signal: {}", line);
+ for (Session session : sessionManager.allSessions()) {
+ var hls = session.getHlsPlaylistState();
+ if (hls == null || !hls.isPlaybackStarted()) {
+ continue;
+ }
+ switch (line) {
+ case "loading", "playing", "paused", "stopped" -> sendPlaybackStateEvent(session, line);
+ case "fcup-master" -> {
+ requestMasterRefresh(session);
+ scheduleMasterPoll(session);
+ }
+ case "rate0" -> hls.setPlaybackRate(0);
+ case "rate1" -> hls.setPlaybackRate(1);
+ case "clear-wait" -> {
+ hls.setWaitingForMasterChange(false);
+ PlaybackInfoOverride.clear();
+ }
+ case "clear-override" -> PlaybackInfoOverride.clear();
+ default -> {
+ if (line.startsWith("info:")) {
+ String[] p = line.substring(5).split(",");
+ if (p.length >= 3) {
+ PlaybackInfoOverride.set(
+ Double.parseDouble(p[0].trim()),
+ Double.parseDouble(p[1].trim()),
+ Double.parseDouble(p[2].trim()));
+ }
+ } else {
+ log.warn("Unknown HLS debug signal: {}", line);
+ }
+ }
+ }
}
}
- public void onMasterRefreshDuringPlayback(Session session, String rewrittenBody) {
+ public void onMasterRefreshDuringPlayback(Session session, String rewrittenBody, String rawBody,
+ List remoteMediaUris) {
var hls = session.getHlsPlaylistState();
if (hls == null || !hls.isPlaybackStarted()) {
return;
}
- boolean changed = hls.recordMasterIfChanged(rewrittenBody);
+ boolean rewrittenChanged = hls.recordMasterIfChanged(rewrittenBody);
+ boolean rawChanged = hls.recordRawMasterIfChanged(rawBody);
+ boolean changed = rewrittenChanged || rawChanged;
if (changed) {
- log.info("HLS master changed, restarting playback session {}", session.getId());
+ log.info("HLS master changed (rewritten={}, raw={}), restarting playback session {}",
+ rewrittenChanged, rawChanged, session.getId());
cancelMasterPoll(session.getId());
hls.setWaitingForMasterChange(false);
- airPlayConsumer.onMediaPlaylist(hls.getPlaylistUriLocal());
+ hls.setPlaybackRate(1);
+ if (remoteMediaUris != null && !remoteMediaUris.isEmpty()) {
+ try {
+ hls.storeMasterPlaylist(rewrittenBody, remoteMediaUris);
+ } catch (Exception e) {
+ log.warn("Failed to refresh media URI list after master change", e);
+ hls.updateMasterPlaylist(rewrittenBody);
+ }
+ } else {
+ hls.updateMasterPlaylist(rewrittenBody);
+ }
+ airPlayConsumer.onPlaylist(hls.getPlaylistUriLocal());
+ sendPlaybackStateEvent(session, "playing");
+ Double seek = hls.takePendingSeekSeconds();
+ if (seek != null && seek > 0) {
+ airPlayConsumer.onSeek(seek);
+ }
} else if (hls.isWaitingForMasterChange()) {
- log.info("HLS master unchanged, scheduling poll session {}", session.getId());
- scheduleMasterPoll(session);
+ if (hls.isPostEosMediaRefreshing()) {
+ log.debug("HLS master unchanged, media refresh still in flight session={}", session.getId());
+ return;
+ }
+ hls.updateMasterPlaylist(rewrittenBody);
+ // Prefer a mediadata sweep after EOS: master URI list often stays identical while
+ // VOD bodies swap (preroll ad → content). First sweep immediately; then every
+ // MEDIA_RESWEEP_EVERY_MASTER_POLLS master polls so we do not starve /event
+ // (playlistRemove / next /play still need the reverse channel).
+ if (!hls.isPostEosMediaSweepDone()
+ || hls.noteMasterPollAndShouldResweepMedia(MEDIA_RESWEEP_EVERY_MASTER_POLLS)) {
+ log.info("HLS master unchanged after EOS, refreshing media playlists session={}", session.getId());
+ hls.beginPostEosMediaRefresh(remoteMediaUris);
+ String first = hls.nextMediaUri();
+ if (first != null) {
+ sendFcupRequest(session, first);
+ } else {
+ hls.markPostEosMediaSweepDone();
+ scheduleMasterPoll(session);
+ }
+ } else {
+ log.debug("HLS master still unchanged, master-only poll session={}", session.getId());
+ scheduleMasterPoll(session);
+ }
} else {
log.info("HLS master refreshed during playback, bytes={}", rewrittenBody.length());
}
}
+ /** Called when a post-EOS media FCUP round finishes (all queued mediadata fetched). */
+ public void onPostEosMediaRefreshComplete(Session session) {
+ var hls = session.getHlsPlaylistState();
+ if (hls == null || !hls.isPostEosMediaRefreshing()) {
+ return;
+ }
+ boolean mediaChanged = hls.finishPostEosMediaRefresh();
+ hls.markPostEosMediaSweepDone();
+ if (mediaChanged) {
+ log.info("HLS media playlists changed after EOS, restarting playback session {}", session.getId());
+ cancelMasterPoll(session.getId());
+ hls.setWaitingForMasterChange(false);
+ hls.setPlaybackRate(1);
+ airPlayConsumer.onPlaylist(hls.getPlaylistUriLocal());
+ sendPlaybackStateEvent(session, "playing");
+ } else {
+ log.info("HLS media unchanged after EOS, polling master session {}", session.getId());
+ scheduleMasterPoll(session);
+ }
+ }
+
public void cancelAllMasterPolls() {
for (Session session : sessionManager.allSessions()) {
cancelMasterPoll(session.getId());
@@ -78,6 +237,33 @@ public void cancelAllMasterPolls() {
}
}
+ public void clearReverseQueue(Session session) {
+ ReverseQueue queue = reverseQueues.remove(session.getId());
+ if (queue != null) {
+ synchronized (queue) {
+ queue.pending.clear();
+ queue.inFlight = false;
+ }
+ }
+ }
+
+ /** Called when the reverse event channel receives HTTP 200 for a POST /event. */
+ public void onReverseEventResponse(ChannelHandlerContext ctx) {
+ for (Session session : sessionManager.allSessions()) {
+ if (session.getReverseContexts().get("event") == ctx) {
+ ReverseQueue queue = reverseQueues.get(session.getId());
+ if (queue == null) {
+ return;
+ }
+ synchronized (queue) {
+ queue.inFlight = false;
+ flushReverseQueue(session, queue);
+ }
+ return;
+ }
+ }
+ }
+
private void requestMasterRefresh(Session session) {
var hls = session.getHlsPlaylistState();
if (hls == null) {
@@ -92,6 +278,7 @@ private void scheduleMasterPoll(Session session) {
var hls = session.getHlsPlaylistState();
if (hls != null && hls.isWaitingForMasterChange()) {
requestMasterRefresh(session);
+ scheduleMasterPoll(session);
}
}, MASTER_POLL_INTERVAL_MS, TimeUnit.MILLISECONDS);
masterPollTasks.put(session.getId(), future);
@@ -104,26 +291,89 @@ private void cancelMasterPoll(String sessionId) {
}
}
- public void sendFcupRequest(Session session, String listUri) {
- var eventContext = session.getReverseContexts().get("event");
- if (eventContext == null) {
- log.error("No reverse event channel for FCUP request {}", listUri);
+ public void sendPlaybackStateEvent(Session session, String state) {
+ var hls = session.getHlsPlaylistState();
+ byte[] body;
+ if (hls != null) {
+ body = PropertyListUtil.preparePlaybackStateEvent(
+ state, hls.getReverseEventSessionId(), hls.getItemUuid(), null);
+ } else {
+ body = PropertyListUtil.preparePlaybackStateEvent(state);
+ }
+ log.info("Playback state event: {} session={}", state, session.getId());
+ enqueueReverseEvent(session, body);
+ }
+
+ /**
+ * Working receiver VOD EOS sequence on reverse {@code POST /event}
+ * (after local player end). Queued FIFO — one in-flight request at a time.
+ */
+ public void sendVodEosEventBurst(Session session) {
+ var hls = session.getHlsPlaylistState();
+ if (hls == null) {
return;
}
+ int sid = hls.getReverseEventSessionId();
+ String itemUuid = hls.getItemUuid();
+ String playId = session.getId();
+ log.info("VOD EOS reverse burst playId={} reverseSessionId={} itemUuid={}",
+ playId, sid, itemUuid);
+ enqueueReverseEvent(session,
+ PropertyListUtil.preparePlaybackStateEvent("loading", sid, itemUuid, null));
+ enqueueReverseEvent(session,
+ PropertyListUtil.prepareVideoTypedEvent("itemPlayedToEnd", sid, itemUuid));
+ enqueueReverseEvent(session,
+ PropertyListUtil.preparePlaybackStateEvent("stopped", sid, itemUuid, "ended"));
+ enqueueReverseEvent(session,
+ PropertyListUtil.prepareItemRemovedEvent(playId, itemUuid));
+ enqueueReverseEvent(session,
+ PropertyListUtil.prepareCurrentItemChangedEvent(sid));
+ enqueueReverseEvent(session,
+ PropertyListUtil.preparePlaybackStateEvent("stopped", sid, itemUuid, null));
+ }
+
+ public void sendFcupRequest(Session session, String listUri) {
int requestId = 1;
var hls = session.getHlsPlaylistState();
if (hls != null) {
requestId = hls.nextFcupRequestId();
}
log.info("FCUP request: url={}, requestId={}, session={}", listUri, requestId, session.getId());
- var requestContent = PropertyListUtil.prepareEventRequest(session.getId(), listUri, requestId);
+ byte[] requestContent = PropertyListUtil.prepareEventRequest(session.getId(), listUri, requestId);
+ enqueueReverseEvent(session, requestContent);
+ }
+ private void enqueueReverseEvent(Session session, byte[] body) {
+ ReverseQueue queue = reverseQueues.computeIfAbsent(session.getId(), id -> new ReverseQueue());
+ synchronized (queue) {
+ queue.pending.add(body);
+ flushReverseQueue(session, queue);
+ }
+ }
+
+ private void flushReverseQueue(Session session, ReverseQueue queue) {
+ if (queue.inFlight || queue.pending.isEmpty()) {
+ return;
+ }
+ var eventContext = session.getReverseContexts().get("event");
+ if (eventContext == null || !eventContext.channel().isActive()) {
+ log.error("No active reverse event channel, dropping {} queued events session={}",
+ queue.pending.size(), session.getId());
+ queue.pending.clear();
+ return;
+ }
+ byte[] body = queue.pending.poll();
+ queue.inFlight = true;
DefaultFullHttpRequest event = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.POST, "/event");
event.headers().add(HttpHeaderNames.CONTENT_TYPE, "text/x-apple-plist+xml");
- event.headers().add(HttpHeaderNames.CONTENT_LENGTH, requestContent.length);
+ event.headers().add(HttpHeaderNames.CONTENT_LENGTH, body.length);
event.headers().add("X-Apple-Session-ID", session.getId());
- event.content().writeBytes(requestContent);
-
+ event.content().writeBytes(body);
eventContext.writeAndFlush(event);
}
+
+ private static final class ReverseQueue {
+ final Queue pending = new ArrayDeque<>();
+ boolean inFlight;
+ }
}
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/PlaybackInfoOverride.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/PlaybackInfoOverride.java
new file mode 100644
index 00000000..6ae75137
--- /dev/null
+++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/control/PlaybackInfoOverride.java
@@ -0,0 +1,28 @@
+package com.github.serezhka.airplay.server.internal.handler.control;
+
+/**
+ * Optional live override for {@code GET /playback-info} while debugging YouTube ad EOS hangs.
+ * Written by {@link HlsFcupService} signal file / auto-nudge; consumed by {@code ControlHandler}.
+ */
+public final class PlaybackInfoOverride {
+
+ private static volatile Snapshot snapshot;
+
+ private PlaybackInfoOverride() {
+ }
+
+ public static void clear() {
+ snapshot = null;
+ }
+
+ public static void set(double duration, double position, double rate) {
+ snapshot = new Snapshot(duration, position, rate, System.nanoTime());
+ }
+
+ public static Snapshot get() {
+ return snapshot;
+ }
+
+ public record Snapshot(double duration, double position, double rate, long setAtNanos) {
+ }
+}
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistState.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistState.java
index 154990b6..3955f9ec 100644
--- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistState.java
+++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistState.java
@@ -1,31 +1,101 @@
package com.github.serezhka.airplay.server.internal.handler.session;
+import io.lindstrom.m3u8.model.MediaPlaylist;
+import io.lindstrom.m3u8.model.MediaSegment;
+import io.lindstrom.m3u8.parser.MediaPlaylistParser;
+import io.lindstrom.m3u8.parser.ParsingMode;
import io.lindstrom.m3u8.parser.PlaylistParserException;
import lombok.Getter;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
@Getter
public class HlsPlaylistState {
+ /** Odd integers 1,3,5,… — reverse-event {@code sessionID} (distinct from play UUID). */
+ private static final AtomicInteger NEXT_REVERSE_SESSION_ID = new AtomicInteger(1);
+
private final String remoteMasterUri;
private final String playlistUriLocal;
+ /** Per-item uuid for reverse video events ({@code params.uuid} / typed {@code uuid}). */
+ private final String itemUuid;
+ /** Integer {@code sessionID} field inside reverse {@code /event} plists for this item. */
+ private final int reverseEventSessionId;
private final List pendingMediaUris = new ArrayList<>();
+ /** STREAM-INF / video itag mediadata URIs (prefetch these before audio-language alts). */
+ private final List videoMediaUris = new ArrayList<>();
private final Map playlists = new LinkedHashMap<>();
+ /** Hashes of last-seen mediadata bodies; survive {@link #invalidatePlaylists()} for EOS compare. */
+ private final Map mediaBodyHashes = new HashMap<>();
+ /** Last FCUP refresh nanoTime per remote playlist URI (rate-limit live re-fetch). */
+ private final Map lastFcupRefreshNanos = new HashMap<>();
private int nextMediaUriIndex;
private int fcupRequestId = 1;
private boolean masterReceived;
private boolean playbackStarted;
private String lastMasterBody;
+ /** Unfiltered FCUP master — AVC rewrite can hide ad→content changes. */
+ private String lastRawMasterBody;
private volatile boolean waitingForMasterChange;
+ private volatile boolean postEosMediaRefreshing;
+ private volatile boolean postEosMediaChanged;
+ /** After EOS, at most one full mediadata FCUP sweep until the next resweep interval. */
+ private volatile boolean postEosMediaSweepDone;
+ /** Master polls while waiting for post-EOS change; drives periodic mediadata resweep. */
+ private int postEosMasterPollCount;
+ /** True once any mediadata lacked {@code #EXT-X-ENDLIST} (sliding live / event window). */
+ private volatile boolean livePlaylist;
+ private Double pendingSeekSeconds;
+ /** Best-effort VOD duration from media playlist {@code #EXTINF} sums. */
+ private volatile double mediaDurationSeconds;
+ /** AirPlay playback rate: {@code 0} paused, {@code 1} playing. */
+ private volatile double playbackRate = 1;
+ /**
+ * {@code AVPlayerActionAtItemEnd}: 0=advance, 1=pause, 2=none.
+ * YouTube sets pause (1) — on EOS we must pause and wait for the next {@code /play}.
+ */
+ private volatile int actionAtItemEnd = 1;
+ /**
+ * After {@code /scrub}, ignore {@code rate=0} until the next {@code rate=1}.
+ * Event latch (not a timer): scrub brackets with rate=0 then rate=1.
+ */
+ private volatile boolean ignorePauseUntilRateOne;
public HlsPlaylistState(String remoteMasterUri, String playlistUriLocal) {
this.remoteMasterUri = remoteMasterUri;
this.playlistUriLocal = playlistUriLocal;
+ this.itemUuid = UUID.randomUUID().toString().toUpperCase();
+ this.reverseEventSessionId = NEXT_REVERSE_SESSION_ID.getAndAdd(2);
+ }
+
+ /** Arm after {@code /scrub}: drop rate=0 until the client sends rate=1. */
+ public void markScrubIgnorePauseUntilPlay() {
+ ignorePauseUntilRateOne = true;
+ }
+
+ public boolean shouldIgnorePause() {
+ return ignorePauseUntilRateOne;
+ }
+
+ public void clearScrubIgnorePause() {
+ ignorePauseUntilRateOne = false;
+ }
+
+ public void setPendingSeekSeconds(Double pendingSeekSeconds) {
+ this.pendingSeekSeconds = pendingSeekSeconds;
+ }
+
+ public Double takePendingSeekSeconds() {
+ Double seek = pendingSeekSeconds;
+ pendingSeekSeconds = null;
+ return seek;
}
public int nextFcupRequestId() {
@@ -33,11 +103,95 @@ public int nextFcupRequestId() {
}
public void putPlaylist(String remoteUri, String body) {
- playlists.put(normalizeUri(remoteUri), body);
+ String key = normalizeUri(remoteUri);
+ playlists.put(key, body);
+ if (remoteUri.contains("mediadata.m3u8")) {
+ boolean endList = body.contains("#EXT-X-ENDLIST");
+ if (!endList) {
+ livePlaylist = true;
+ mediaDurationSeconds = 0;
+ } else if (!livePlaylist && !waitingForMasterChange) {
+ // Only finite VOD may raise duration. Live windows must not.
+ // Freeze duration while waiting for playlistRemove — post-EOS FCUP can
+ // deliver a longer next-item body and poison /playback-info (7s ad → 15.6).
+ double duration = sumMediaDurationSeconds(body);
+ if (duration > 0 && duration > mediaDurationSeconds) {
+ mediaDurationSeconds = duration;
+ }
+ }
+ int hash = body.hashCode();
+ Integer prev = mediaBodyHashes.put(key, hash);
+ if (postEosMediaRefreshing && prev != null && prev != hash) {
+ postEosMediaChanged = true;
+ }
+ }
+ }
+
+ public boolean isLivePlaylist() {
+ return livePlaylist;
+ }
+
+ /**
+ * @return true if a FCUP refresh for this URI should be sent now (rate-limited).
+ */
+ public boolean shouldRefreshPlaylist(String remoteUri, long minIntervalNanos) {
+ String key = normalizeUri(remoteUri);
+ long now = System.nanoTime();
+ Long prev = lastFcupRefreshNanos.get(key);
+ if (prev != null && now - prev < minIntervalNanos) {
+ return false;
+ }
+ lastFcupRefreshNanos.put(key, now);
+ return true;
+ }
+
+ public void setPlaybackRate(double playbackRate) {
+ this.playbackRate = playbackRate <= 0 ? 0 : 1;
+ }
+
+ public void setActionAtItemEnd(int actionAtItemEnd) {
+ this.actionAtItemEnd = actionAtItemEnd;
+ }
+
+ public int getActionAtItemEnd() {
+ return actionAtItemEnd;
}
public void invalidatePlaylists() {
playlists.clear();
+ lastFcupRefreshNanos.clear();
+ mediaDurationSeconds = 0;
+ }
+
+ static double sumMediaDurationSeconds(String mediaPlaylistBody) {
+ if (mediaPlaylistBody == null || mediaPlaylistBody.isBlank()) {
+ return 0;
+ }
+ try {
+ MediaPlaylist playlist = new MediaPlaylistParser(ParsingMode.LENIENT).readPlaylist(mediaPlaylistBody);
+ double sum = 0;
+ for (MediaSegment segment : playlist.mediaSegments()) {
+ sum += segment.duration();
+ }
+ if (sum > 0) {
+ return sum;
+ }
+ } catch (Exception ignored) {
+ // use #EXTINF line scan below
+ }
+ double sum = 0;
+ for (String line : mediaPlaylistBody.split("\n")) {
+ String trimmed = line.trim();
+ if (trimmed.startsWith("#EXTINF:")) {
+ String value = trimmed.substring("#EXTINF:".length()).split(",", 2)[0].trim();
+ try {
+ sum += Double.parseDouble(value);
+ } catch (NumberFormatException ignored) {
+ // skip
+ }
+ }
+ }
+ return sum;
}
public void updateMasterPlaylist(String rewrittenMaster) {
@@ -53,35 +207,175 @@ public boolean recordMasterIfChanged(String rewrittenMaster) {
return true;
}
+ /** Track raw (pre-AVC-filter) master; returns true when bytes differ from last EOS cycle. */
+ public boolean recordRawMasterIfChanged(String rawMaster) {
+ if (rawMaster == null) {
+ return false;
+ }
+ if (lastRawMasterBody != null && rawMaster.equals(lastRawMasterBody)) {
+ return false;
+ }
+ lastRawMasterBody = rawMaster;
+ return true;
+ }
+
+ /**
+ * After ad EOS YouTube often keeps the same master URI list and only updates mediadata.
+ * Re-queue media FCUP so we can detect that and restart.
+ */
+ public void beginPostEosMediaRefresh(List remoteMediaUris) {
+ postEosMediaRefreshing = true;
+ postEosMediaChanged = false;
+ pendingMediaUris.clear();
+ if (remoteMediaUris != null) {
+ for (String uri : remoteMediaUris) {
+ pendingMediaUris.add(normalizeUri(uri));
+ }
+ }
+ nextMediaUriIndex = 0;
+ }
+
+ public boolean isPostEosMediaRefreshing() {
+ return postEosMediaRefreshing;
+ }
+
+ /** @return true if any mediadata body changed during the refresh */
+ public boolean finishPostEosMediaRefresh() {
+ postEosMediaRefreshing = false;
+ boolean changed = postEosMediaChanged;
+ postEosMediaChanged = false;
+ return changed;
+ }
+
+ public void cancelPostEosMediaRefresh() {
+ postEosMediaRefreshing = false;
+ postEosMediaChanged = false;
+ }
+
+ public void resetPostEosMediaSweep() {
+ postEosMediaSweepDone = false;
+ postEosMasterPollCount = 0;
+ }
+
+ public void markPostEosMediaSweepDone() {
+ postEosMediaSweepDone = true;
+ }
+
+ public boolean isPostEosMediaSweepDone() {
+ return postEosMediaSweepDone;
+ }
+
+ /**
+ * Count a master-only poll while waiting after EOS. Every {@code everyN} polls (inclusive),
+ * allow another mediadata FCUP sweep — YouTube often keeps the same master and only updates
+ * VOD mediadata on ad→content.
+ *
+ * @param everyN must be >= 1; values <= 0 disable periodic resweep
+ */
+ public boolean noteMasterPollAndShouldResweepMedia(int everyN) {
+ if (everyN <= 0) {
+ return false;
+ }
+ postEosMasterPollCount++;
+ if (postEosMasterPollCount % everyN != 0) {
+ return false;
+ }
+ postEosMediaSweepDone = false;
+ return true;
+ }
+
+ public int getPostEosMasterPollCount() {
+ return postEosMasterPollCount;
+ }
+
public boolean isWaitingForMasterChange() {
return waitingForMasterChange;
}
public void setWaitingForMasterChange(boolean waitingForMasterChange) {
this.waitingForMasterChange = waitingForMasterChange;
+ if (!waitingForMasterChange) {
+ cancelPostEosMediaRefresh();
+ postEosMediaSweepDone = false;
+ postEosMasterPollCount = 0;
+ }
}
public String getPlaylist(String remoteUri) {
return playlists.get(normalizeUri(remoteUri));
}
- public void storeMasterPlaylist(String rewrittenMaster, String rawMaster) throws PlaylistParserException {
- List mediaUris = HlsUriRewrite.extractMediaUris(rawMaster);
- if (mediaUris.isEmpty()) {
+ public void storeMasterPlaylist(String rewrittenMaster, List remoteMediaUris) throws PlaylistParserException {
+ // rewrittenMaster uses local http URLs for the media consumer; remoteMediaUris stay
+ // mlhls://… so FCUP prefetch talks to the AirPlay client, not loopback.
+ if (remoteMediaUris == null || remoteMediaUris.isEmpty()) {
throw new PlaylistParserException("No media playlists found in master playlist");
}
masterReceived = true;
lastMasterBody = rewrittenMaster;
putPlaylist(remoteMasterUri, rewrittenMaster);
pendingMediaUris.clear();
- pendingMediaUris.addAll(mediaUris);
+ videoMediaUris.clear();
+ List audioOrOther = new ArrayList<>();
+ for (String uri : remoteMediaUris) {
+ String normalized = normalizeUri(uri);
+ if (isLikelyVideoMediaUri(normalized)) {
+ videoMediaUris.add(normalized);
+ pendingMediaUris.add(normalized);
+ } else {
+ audioOrOther.add(normalized);
+ }
+ }
+ // Prefetch video before dozens of audio-language alternatives.
+ pendingMediaUris.addAll(audioOrOther);
nextMediaUriIndex = 0;
}
+ /** True when at least one video mediadata body is cached (safe to start the player). */
+ public boolean hasCachedVideoMedia() {
+ for (String uri : videoMediaUris) {
+ if (playlists.containsKey(uri)) {
+ return true;
+ }
+ }
+ for (String key : playlists.keySet()) {
+ if (isLikelyVideoMediaUri(key)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public int videoMediaUriCount() {
+ return videoMediaUris.size();
+ }
+
+ /**
+ * YouTube audio alternatives use itag 233/234 and/or {@code /xtags/}; STREAM-INF video
+ * playlists use other itags without xtags.
+ */
+ static boolean isLikelyVideoMediaUri(String uri) {
+ if (uri == null || !uri.contains("mediadata")) {
+ return false;
+ }
+ if (uri.contains("/xtags/")) {
+ return false;
+ }
+ if (uri.contains("/itag/233/") || uri.contains("/itag/234/")) {
+ return false;
+ }
+ return true;
+ }
+
public boolean hasMoreMediaUris() {
return nextMediaUriIndex < pendingMediaUris.size();
}
+ /** Stop FCUP media prefetch (e.g. on EOS) so late responses don't starve master refresh. */
+ public void abortMediaPrefetch() {
+ nextMediaUriIndex = pendingMediaUris.size();
+ }
+
public String nextMediaUri() {
if (!hasMoreMediaUris()) {
return null;
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewrite.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewrite.java
index f665d9ae..9c9de05a 100644
--- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewrite.java
+++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewrite.java
@@ -22,6 +22,9 @@ private HlsUriRewrite() {
}
public static String toLocalUri(String remoteUri, String baseUrl, String sessionId) {
+ if (remoteUri.startsWith("http://") || remoteUri.startsWith("https://")) {
+ return remoteUri;
+ }
String path = remoteUri.replace("mlhls://localhost", "");
if (!path.startsWith("/")) {
path = "/" + path;
@@ -43,6 +46,86 @@ public static String rewritePlaylist(String playlist, String baseUrl, String ses
return rewritten.toString();
}
+ /**
+ * Keep H.264 ({@code avc1}) variants only. YouTube masters mix AVC + VP9/AV1; a single
+ * HLS consumer cannot switch video codecs mid-playlist. Also drops subtitle renditions.
+ */
+ public static String preferAvcVariants(String masterPlaylist) {
+ String[] lines = masterPlaylist.split("\\R", -1);
+ List headers = new ArrayList<>();
+ List audioMedia = new ArrayList<>();
+ List variants = new ArrayList<>();
+ Set audioGroups = new LinkedHashSet<>();
+
+ for (int i = 0; i < lines.length; i++) {
+ String line = lines[i];
+ if (line.startsWith("#EXT-X-STREAM-INF:")) {
+ String info = line;
+ String uri = (i + 1 < lines.length) ? lines[++i] : "";
+ if (isAvcStreamInfo(info)) {
+ variants.add(info);
+ variants.add(uri);
+ extractAttr(info, "AUDIO").ifPresent(audioGroups::add);
+ }
+ continue;
+ }
+ if (line.startsWith("#EXT-X-MEDIA:")) {
+ String type = extractAttr(line, "TYPE").orElse("");
+ if ("AUDIO".equalsIgnoreCase(type)) {
+ audioMedia.add(line);
+ }
+ // drop SUBTITLES / CLOSED-CAPTIONS / other
+ continue;
+ }
+ if (!line.isEmpty() || headers.isEmpty()) {
+ headers.add(line);
+ }
+ }
+
+ if (variants.isEmpty()) {
+ return masterPlaylist;
+ }
+
+ StringBuilder out = new StringBuilder();
+ for (String header : headers) {
+ if (header.isEmpty() && out.isEmpty()) {
+ continue;
+ }
+ out.append(header).append('\n');
+ }
+ for (String media : audioMedia) {
+ String group = extractAttr(media, "GROUP-ID").orElse("");
+ if (audioGroups.isEmpty() || audioGroups.contains(group)) {
+ out.append(media).append('\n');
+ }
+ }
+ for (String variantLine : variants) {
+ out.append(variantLine).append('\n');
+ }
+ return out.toString();
+ }
+
+ private static boolean isAvcStreamInfo(String streamInf) {
+ String codecs = extractAttr(streamInf, "CODECS").orElse("").toLowerCase();
+ if (codecs.isBlank()) {
+ return true;
+ }
+ boolean avc = codecs.contains("avc1") || codecs.contains("avc3");
+ boolean vp9 = codecs.contains("vp09") || codecs.contains("vp9");
+ boolean av1 = codecs.contains("av01") || codecs.contains("av1.");
+ return avc && !vp9 && !av1;
+ }
+
+ private static java.util.Optional extractAttr(String line, String name) {
+ Pattern p = Pattern.compile(name + "=(?:\"([^\"]*)\"|([^,]*))", Pattern.CASE_INSENSITIVE);
+ Matcher m = p.matcher(line);
+ if (!m.find()) {
+ return java.util.Optional.empty();
+ }
+ String v = m.group(1) != null ? m.group(1) : m.group(2);
+ return java.util.Optional.ofNullable(v).map(String::trim).filter(s -> !s.isEmpty());
+ }
+
public static List extractMediaUris(String masterPlaylist) throws PlaylistParserException {
try {
return extractMediaUrisParsed(masterPlaylist);
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/Session.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/Session.java
index c081132b..c91a157c 100644
--- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/Session.java
+++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/session/Session.java
@@ -1,6 +1,6 @@
package com.github.serezhka.airplay.server.internal.handler.session;
-import com.github.serezhka.airplay.lib.AirPlay;
+import com.github.serezhka.airplay.protocol.FairPlaySession;
import com.github.serezhka.airplay.server.internal.AudioControlServer;
import com.github.serezhka.airplay.server.internal.AudioServer;
import com.github.serezhka.airplay.server.internal.VideoServer;
@@ -17,7 +17,7 @@ public class Session {
private final String id;
- private final AirPlay airPlay;
+ private final FairPlaySession airPlay;
private final VideoServer videoServer;
private final AudioServer audioServer;
private final AudioControlServer audioControlServer;
@@ -27,7 +27,7 @@ public class Session {
Session(String id) {
this.id = id;
- airPlay = new AirPlay();
+ airPlay = new FairPlaySession();
videoServer = new VideoServer(airPlay);
audioServer = new AudioServer(airPlay);
audioControlServer = new AudioControlServer();
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/AirPlayVolume.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/AirPlayVolume.java
new file mode 100644
index 00000000..e6931914
--- /dev/null
+++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/AirPlayVolume.java
@@ -0,0 +1,40 @@
+package com.github.serezhka.airplay.server.internal.handler.util;
+
+/**
+ * AirPlay RTSP volume is typically dB in roughly {@code [-30, 0]} (mute often {@code -144}).
+ * HTTP {@code /play} uses linear {@code [0, 1]}.
+ */
+public final class AirPlayVolume {
+
+ private AirPlayVolume() {
+ }
+
+ public static double clampLinear(double linear) {
+ if (Double.isNaN(linear)) {
+ return 1.0;
+ }
+ return Math.max(0.0, Math.min(1.0, linear));
+ }
+
+ /** Convert RTSP dB volume to linear gain in {@code [0, 1]}. */
+ public static double fromDecibels(double db) {
+ if (db <= -144.0) {
+ return 0.0;
+ }
+ double clamped = Math.max(-30.0, Math.min(0.0, db));
+ return clampLinear(Math.pow(10.0, clamped / 20.0));
+ }
+
+ /** Convert linear gain to RTSP dB for {@code GET_PARAMETER}. */
+ public static double toDecibels(double linear) {
+ double v = clampLinear(linear);
+ if (v <= 1e-6) {
+ return -144.0;
+ }
+ return 20.0 * Math.log10(v);
+ }
+
+ public static String formatRtspParameter(double linear) {
+ return String.format("volume: %.6f\r\n", toDecibels(linear));
+ }
+}
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java
index bb3702a3..3defd511 100644
--- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java
+++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtil.java
@@ -3,8 +3,9 @@
import com.dd.plist.BinaryPropertyListWriter;
import com.dd.plist.NSArray;
import com.dd.plist.NSDictionary;
+import com.github.serezhka.airplay.server.discovery.AdvertisedReceiver;
import com.github.serezhka.airplay.server.AirPlayConfig;
-import com.github.serezhka.airplay.server.AirPlayConsumer;
+import com.github.serezhka.airplay.server.Playback;
import lombok.extern.slf4j.Slf4j;
import java.nio.charset.StandardCharsets;
@@ -57,14 +58,14 @@ public static byte[] prepareInfoResponse(AirPlayConfig airPlayConfig) throws Exc
response.put("audioFormats", audioFormats);
response.put("audioLatencies", audioLatencies);
response.put("displays", displays);
- response.put("features", 0x527FFEE6L);
+ response.put("features", AdvertisedReceiver.FEATURES);
response.put("keepAliveSendStatsAsBody", 1);
- response.put("model", "AppleTV3,2");
+ response.put("model", AdvertisedReceiver.MODEL);
response.put("name", "Apple TV");
response.put("pi", "b08f5a79-db29-4384-b456-a4784d9e6055");
- response.put("sourceVersion", "220.68");
- response.put("statusFlags", 68);
- response.put("vv", 2);
+ response.put("sourceVersion", AdvertisedReceiver.SOURCE_VERSION);
+ response.put("statusFlags", AdvertisedReceiver.STATUS_FLAGS);
+ response.put("vv", AdvertisedReceiver.VV);
// response.put("pk", new NSData("XYMxJlYMsZoUGTcneJbw/UN7poAeshCsTDnZAHLXDag="));
return BinaryPropertyListWriter.writeToArray(response);
@@ -107,36 +108,121 @@ public static byte[] prepareServerInfoResponse() {
return response.toXMLPropertyList().getBytes(StandardCharsets.UTF_8);
}
- public static byte[] preparePlaybackInfoResponse(AirPlayConsumer.PlaybackInfo playbackInfo) {
+ public static byte[] preparePlaybackInfoResponse(Playback.Info playbackInfo) {
NSDictionary response = new NSDictionary();
double duration = Math.max(0, playbackInfo.duration());
double position = Math.max(0, playbackInfo.position());
if (duration > 0) {
position = Math.min(position, duration);
}
+ double rate = playbackInfo.rate() <= 0 ? 0 : 1;
response.put("duration", duration);
NSDictionary loadedTimeRanges = new NSDictionary();
loadedTimeRanges.put("duration", duration);
loadedTimeRanges.put("start", 0.0);
response.put("loadedTimeRanges", new NSArray(loadedTimeRanges));
boolean hasDuration = duration > 0;
- boolean hasPosition = position > 0;
- boolean atEnd = hasDuration && position >= duration;
- boolean bufferEmpty = !hasDuration && !hasPosition;
- response.put("playbackBufferEmpty", bufferEmpty);
- response.put("playbackBufferFull", hasDuration && !atEnd);
- response.put("playbackLikelyToKeepUp", hasDuration && !atEnd);
+ // Do NOT force rate=0 when position≈duration. That made short VOD ads look like a
+ // user pause; YouTube then stuck until Skip (ControlHandler pins rate=1 while waiting
+ // for playlistRemove — dump 20260916-164913 kept rate=1 through the gap).
+ //
+ // Buffer flags match known receiver /playback-info templates (and
+ // reverse_engineering/get_playback_info_response.txt): empty=true, full=false,
+ // keepUp=true while readyToPlay. Flipping empty/full with hasDuration diverged from
+ // that and is a candidate cause of VOD ad EOS hangs.
+ if (hasDuration) {
+ response.put("playbackBufferEmpty", true);
+ response.put("playbackBufferFull", false);
+ response.put("playbackLikelyToKeepUp", true);
+ response.put("readyToPlay", true);
+ } else {
+ response.put("playbackBufferEmpty", true);
+ response.put("playbackBufferFull", false);
+ response.put("playbackLikelyToKeepUp", false);
+ response.put("readyToPlay", false);
+ }
response.put("position", position);
- response.put("rate", atEnd ? 0 : 1);
- response.put("readyToPlay", hasDuration);
+ response.put("rate", rate);
NSDictionary seekableTimeRanges = new NSDictionary();
seekableTimeRanges.put("duration", duration);
seekableTimeRanges.put("start", 0.0);
response.put("seekableTimeRanges", new NSArray(seekableTimeRanges));
- log.debug("Playback info: duration={}, position={}", duration, position);
+ log.debug("Playback info: duration={}, position={}, rate={}", duration, position, rate);
return response.toXMLPropertyList().getBytes(StandardCharsets.UTF_8);
}
+ /**
+ * Playback state event sent to the client over the reverse HTTP channel.
+ * States: {@code loading}, {@code playing}, {@code paused}, {@code stopped}.
+ */
+ public static byte[] preparePlaybackStateEvent(String state) {
+ return preparePlaybackStateEvent(state, 1, null, null);
+ }
+
+ /**
+ * Reverse {@code POST /event} with {@code category=video} and a {@code state}.
+ *
+ * @param reason optional; working VOD EOS uses {@code "ended"} with {@code stopped}
+ * @param itemUuid optional playlist-item uuid under {@code params}
+ */
+ public static byte[] preparePlaybackStateEvent(String state, int reverseSessionId,
+ String itemUuid, String reason) {
+ NSDictionary event = new NSDictionary();
+ event.put("category", "video");
+ event.put("sessionID", reverseSessionId);
+ event.put("state", state);
+ if (reason != null) {
+ event.put("reason", reason);
+ }
+ if (itemUuid != null) {
+ NSDictionary params = new NSDictionary();
+ params.put("uuid", itemUuid);
+ event.put("params", params);
+ }
+ return event.toXMLPropertyList().getBytes(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Typed video event ({@code itemPlayedToEnd}, etc.) with top-level {@code uuid}.
+ */
+ public static byte[] prepareVideoTypedEvent(String type, int reverseSessionId, String itemUuid) {
+ NSDictionary event = new NSDictionary();
+ event.put("category", "video");
+ event.put("sessionID", reverseSessionId);
+ event.put("type", type);
+ if (itemUuid != null) {
+ event.put("uuid", itemUuid);
+ }
+ return event.toXMLPropertyList().getBytes(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * {@code itemRemoved}: {@code sessionID} is the play UUID string (X-Apple-Session-ID), not the
+ * integer reverse-event session id.
+ */
+ public static byte[] prepareItemRemovedEvent(String playSessionId, String itemUuid) {
+ NSDictionary event = new NSDictionary();
+ event.put("category", "video");
+ event.put("sessionID", playSessionId);
+ event.put("type", "itemRemoved");
+ if (itemUuid != null) {
+ event.put("uuid", itemUuid);
+ }
+ return event.toXMLPropertyList().getBytes(StandardCharsets.UTF_8);
+ }
+
+ public static byte[] prepareCurrentItemChangedEvent(int reverseSessionId) {
+ NSDictionary event = new NSDictionary();
+ event.put("category", "video");
+ event.put("sessionID", reverseSessionId);
+ event.put("type", "currentItemChanged");
+ return event.toXMLPropertyList().getBytes(StandardCharsets.UTF_8);
+ }
+
+ public static byte[] prepareEmptyPropertyResponse() {
+ return new NSDictionary().toXMLPropertyList().getBytes(StandardCharsets.UTF_8);
+ }
+
public static byte[] prepareEventRequest(String sessionId, String listUri, int requestId) {
NSDictionary headers = new NSDictionary();
headers.put("X-Playback-Session-Id", sessionId);
diff --git a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/video/VideoHandler.java b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/video/VideoHandler.java
index 5fe3adae..a2546936 100644
--- a/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/video/VideoHandler.java
+++ b/server/src/main/java/com/github/serezhka/airplay/server/internal/handler/video/VideoHandler.java
@@ -1,7 +1,7 @@
package com.github.serezhka.airplay.server.internal.handler.video;
-import com.github.serezhka.airplay.lib.AirPlay;
-import com.github.serezhka.airplay.server.AirPlayConsumer;
+import com.github.serezhka.airplay.protocol.FairPlaySession;
+import com.github.serezhka.airplay.server.Playback;
import com.github.serezhka.airplay.server.internal.packet.VideoPacket;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
@@ -16,8 +16,8 @@
@ChannelHandler.Sharable
public class VideoHandler extends ChannelInboundHandlerAdapter {
- private final AirPlay airPlay;
- private final AirPlayConsumer dataConsumer;
+ private final FairPlaySession airPlay;
+ private final Playback dataConsumer;
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
@@ -27,7 +27,10 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) {
// Type 0 only: type 5 is an unencrypted streaming-report plist; decrypting it
// advances AES-CTR and corrupts subsequent video frames.
airPlay.decryptVideo(packet.getPayload());
- toAnnexB(packet.getPayload());
+ // Never feed partial/corrupt Annex-B to the consumer.
+ if (!toAnnexB(packet.getPayload())) {
+ return;
+ }
dataConsumer.onVideo(packet.getPayload());
} else if (packet.getPayloadType() == 1) {
byte[] spsPps = prepareSpsPpsNALUnits(packet.getPayload());
@@ -38,7 +41,8 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) {
}
}
- static void toAnnexB(byte[] payload) {
+ /** @return false if length-prefixed NALs are invalid (likely bad decrypt) */
+ static boolean toAnnexB(byte[] payload) {
int idx = 0;
while (idx + 4 <= payload.length) {
int naluSize = (payload[idx + 3] & 0xFF)
@@ -47,7 +51,7 @@ static void toAnnexB(byte[] payload) {
| ((payload[idx] & 0xFF) << 24);
if (naluSize <= 0 || idx + 4 + naluSize > payload.length) {
log.error("Video packet contains corrupted NAL unit. It might be decrypt error");
- return;
+ return false;
}
payload[idx] = 0;
payload[idx + 1] = 0;
@@ -55,6 +59,7 @@ static void toAnnexB(byte[] payload) {
payload[idx + 3] = 1;
idx += naluSize + 4;
}
+ return true;
}
private byte[] prepareSpsPpsNALUnits(byte[] payload) {
diff --git a/server/src/test/java/com/github/serezhka/airplay/server/LayeringTest.java b/server/src/test/java/com/github/serezhka/airplay/server/LayeringTest.java
new file mode 100644
index 00000000..03031a1a
--- /dev/null
+++ b/server/src/test/java/com/github/serezhka/airplay/server/LayeringTest.java
@@ -0,0 +1,33 @@
+package com.github.serezhka.airplay.server;
+
+import com.tngtech.archunit.core.importer.ImportOption;
+import com.tngtech.archunit.junit.AnalyzeClasses;
+import com.tngtech.archunit.junit.ArchTest;
+import com.tngtech.archunit.lang.ArchRule;
+
+import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
+
+@AnalyzeClasses(
+ packages = {
+ "com.github.serezhka.airplay.protocol",
+ "com.github.serezhka.airplay.server"
+ },
+ importOptions = ImportOption.DoNotIncludeTests.class
+)
+class LayeringTest {
+
+ @ArchTest
+ static final ArchRule protocolStaysFreeOfReceiverAndPlayers = noClasses()
+ .that().resideInAPackage("com.github.serezhka.airplay.protocol..")
+ .should().dependOnClassesThat().resideInAnyPackage(
+ "com.github.serezhka.airplay.server..",
+ "com.github.serezhka.airplay.player..",
+ "io.netty..",
+ "javax.jmdns.."
+ );
+
+ @ArchTest
+ static final ArchRule serverDoesNotDependOnPlayers = noClasses()
+ .that().resideInAPackage("com.github.serezhka.airplay.server..")
+ .should().dependOnClassesThat().resideInAPackage("com.github.serezhka.airplay.player..");
+}
diff --git a/server/src/test/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiverTest.java b/server/src/test/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiverTest.java
new file mode 100644
index 00000000..746b3790
--- /dev/null
+++ b/server/src/test/java/com/github/serezhka/airplay/server/discovery/AdvertisedReceiverTest.java
@@ -0,0 +1,20 @@
+package com.github.serezhka.airplay.server.discovery;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class ReceiverProfileTest {
+
+ @Test
+ void matchesHistoricHlsEraMask() {
+ assertEquals(0x5A7FFFF7, AdvertisedReceiver.FEATURES_LO);
+ assertEquals(0x1E, AdvertisedReceiver.FEATURES_HI);
+ assertEquals(130367356919L, AdvertisedReceiver.FEATURES);
+ }
+
+ @Test
+ void txtMatchesLoHi() {
+ assertEquals("0x5A7FFFF7,0x1E", AdvertisedReceiver.FEATURES_TXT);
+ }
+}
diff --git a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupServiceTest.java b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupServiceTest.java
new file mode 100644
index 00000000..ff29ec68
--- /dev/null
+++ b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/control/HlsFcupServiceTest.java
@@ -0,0 +1,183 @@
+package com.github.serezhka.airplay.server.internal.handler.control;
+
+import com.github.serezhka.airplay.server.Playback;
+import com.github.serezhka.airplay.server.internal.handler.session.HlsPlaylistState;
+import com.github.serezhka.airplay.server.internal.handler.session.SessionManager;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class HlsFcupServiceTest {
+
+ private final SessionManager sessions = new SessionManager();
+ private final List playlists = new ArrayList<>();
+ private final List reverseBodies = new CopyOnWriteArrayList<>();
+ private final AtomicInteger pauseCalls = new AtomicInteger();
+ private final AtomicInteger resumeCalls = new AtomicInteger();
+ private final Playback consumer = new Playback() {
+ @Override
+ public void onVideoFormat(com.github.serezhka.airplay.protocol.media.VideoStreamInfo videoStreamInfo) {
+ }
+
+ @Override
+ public void onVideo(byte[] bytes) {
+ }
+
+ @Override
+ public void onVideoSrcDisconnect() {
+ }
+
+ @Override
+ public void onAudioFormat(com.github.serezhka.airplay.protocol.media.AudioStreamInfo audioStreamInfo) {
+ }
+
+ @Override
+ public void onAudio(byte[] bytes) {
+ }
+
+ @Override
+ public void onAudioSrcDisconnect() {
+ }
+
+ @Override
+ public void onPlaylist(String playlistUri) {
+ playlists.add(playlistUri);
+ }
+
+ @Override
+ public void onPause() {
+ pauseCalls.incrementAndGet();
+ }
+
+ @Override
+ public void onResume() {
+ resumeCalls.incrementAndGet();
+ }
+ };
+ private final HlsFcupService service = new HlsFcupService(sessions, consumer) {
+ @Override
+ public void sendPlaybackStateEvent(
+ com.github.serezhka.airplay.server.internal.handler.session.Session session, String state) {
+ reverseBodies.add("state:" + state);
+ super.sendPlaybackStateEvent(session, state);
+ }
+
+ @Override
+ public void sendVodEosEventBurst(
+ com.github.serezhka.airplay.server.internal.handler.session.Session session) {
+ // Capture labels without needing an active reverse channel.
+ reverseBodies.add("state:loading");
+ reverseBodies.add("type:itemPlayedToEnd");
+ reverseBodies.add("state:stopped:ended");
+ reverseBodies.add("type:itemRemoved");
+ reverseBodies.add("type:currentItemChanged");
+ reverseBodies.add("state:stopped");
+ }
+ };
+
+ @AfterEach
+ void tearDown() {
+ service.cancelAllMasterPolls();
+ }
+
+ @Test
+ void vodEosEmitsReverseEventBurstWithoutMediaSweep() {
+ var session = sessions.getSession("vod-ad");
+ var hls = new HlsPlaylistState(
+ "mlhls://localhost/master.m3u8",
+ "http://127.0.0.1/playlist/master.m3u8?session=vod-ad");
+ hls.setActionAtItemEnd(1);
+ hls.markPlaybackStarted();
+ String mediaUri = "mlhls://localhost/itag/232/mediadata.m3u8";
+ hls.putPlaylist(mediaUri, "#EXTM3U\n#EXTINF:6.0,\nad.ts\n#EXT-X-ENDLIST\n");
+ String master = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\nmediadata.m3u8\n";
+ hls.recordMasterIfChanged(master);
+ hls.recordRawMasterIfChanged("raw-master");
+ session.setHlsPlaylistState(hls);
+
+ service.refreshActivePlaylists();
+
+ assertTrue(hls.isWaitingForMasterChange());
+ assertEquals(1.0, hls.getPlaybackRate());
+ assertEquals(List.of(
+ "state:loading",
+ "type:itemPlayedToEnd",
+ "state:stopped:ended",
+ "type:itemRemoved",
+ "type:currentItemChanged",
+ "state:stopped"), reverseBodies);
+ assertFalse(hls.isPostEosMediaRefreshing());
+ assertTrue(playlists.isEmpty());
+ assertFalse(reverseBodies.stream().anyMatch(s -> s.equals("state:paused")));
+ }
+
+ @Test
+ void liveEosStillUsesLoading() {
+ var session = sessions.getSession("live");
+ var hls = new HlsPlaylistState(
+ "mlhls://localhost/master.m3u8",
+ "http://127.0.0.1/playlist/master.m3u8?session=live");
+ hls.setActionAtItemEnd(1);
+ hls.markPlaybackStarted();
+ hls.putPlaylist(
+ "mlhls://localhost/itag/232/mediadata.m3u8",
+ "#EXTM3U\n#EXTINF:6.0,\nseg.ts\n"); // no ENDLIST → live
+ session.setHlsPlaylistState(hls);
+
+ service.refreshActivePlaylists();
+
+ assertTrue(hls.isWaitingForMasterChange());
+ assertTrue(hls.isLivePlaylist());
+ assertTrue(reverseBodies.contains("state:loading"));
+ assertFalse(reverseBodies.contains("type:itemPlayedToEnd"));
+ }
+
+ @Test
+ void vodEosBurstXmlShapes() {
+ var session = sessions.getSession("shape");
+ var hls = new HlsPlaylistState(
+ "mlhls://localhost/master.m3u8",
+ "http://127.0.0.1/playlist/master.m3u8?session=shape");
+ session.setHlsPlaylistState(hls);
+ // Exercise real PropertyListUtil path via a service that records bodies.
+ List xmls = new ArrayList<>();
+ HlsFcupService recording = new HlsFcupService(sessions, consumer) {
+ @Override
+ public void sendVodEosEventBurst(
+ com.github.serezhka.airplay.server.internal.handler.session.Session s) {
+ var st = s.getHlsPlaylistState();
+ int sid = st.getReverseEventSessionId();
+ String item = st.getItemUuid();
+ xmls.add(new String(com.github.serezhka.airplay.server.internal.handler.util.PropertyListUtil
+ .preparePlaybackStateEvent("loading", sid, item, null), StandardCharsets.UTF_8));
+ xmls.add(new String(com.github.serezhka.airplay.server.internal.handler.util.PropertyListUtil
+ .prepareVideoTypedEvent("itemPlayedToEnd", sid, item), StandardCharsets.UTF_8));
+ xmls.add(new String(com.github.serezhka.airplay.server.internal.handler.util.PropertyListUtil
+ .preparePlaybackStateEvent("stopped", sid, item, "ended"), StandardCharsets.UTF_8));
+ xmls.add(new String(com.github.serezhka.airplay.server.internal.handler.util.PropertyListUtil
+ .prepareItemRemovedEvent(s.getId(), item), StandardCharsets.UTF_8));
+ xmls.add(new String(com.github.serezhka.airplay.server.internal.handler.util.PropertyListUtil
+ .prepareCurrentItemChangedEvent(sid), StandardCharsets.UTF_8));
+ }
+ };
+ recording.sendVodEosEventBurst(session);
+
+ assertTrue(xmls.get(0).contains("loading"));
+ assertTrue(xmls.get(0).contains(hls.getItemUuid()));
+ assertTrue(xmls.get(1).contains("itemPlayedToEnd"));
+ assertTrue(xmls.get(2).contains("ended"));
+ assertTrue(xmls.get(3).contains("itemRemoved"));
+ assertTrue(xmls.get(3).contains("shape"));
+ assertTrue(xmls.get(4).contains("currentItemChanged"));
+ recording.cancelAllMasterPolls();
+ }
+}
diff --git a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistStateTest.java b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistStateTest.java
index 3f758785..95c0619d 100644
--- a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistStateTest.java
+++ b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsPlaylistStateTest.java
@@ -5,6 +5,7 @@
import java.util.Base64;
import java.util.List;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -32,4 +33,204 @@ void recordMasterIfChangedDetectsIdenticalAndChangedBodies() {
assertFalse(hls.recordMasterIfChanged(masterV1));
assertTrue(hls.recordMasterIfChanged(masterV2));
}
+
+ @Test
+ void storeMasterPlaylistUsesFilteredAvcOnlyUris() throws Exception {
+ String mixed = """
+ #EXTM3U
+ #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="233",NAME="Default",DEFAULT=YES,URI="mlhls://localhost/itag/233/mediadata.m3u8"
+ #EXT-X-STREAM-INF:BANDWIDTH=1000,CODECS="avc1.4D401E,mp4a.40.2",AUDIO="233"
+ mlhls://localhost/itag/136/mediadata.m3u8
+ #EXT-X-STREAM-INF:BANDWIDTH=2000,CODECS="vp09.00.41.08,mp4a.40.2",AUDIO="233"
+ mlhls://localhost/itag/248/mediadata.m3u8
+ """;
+ String filtered = HlsUriRewrite.preferAvcVariants(mixed);
+ List remoteUris = HlsUriRewrite.extractMediaUris(filtered);
+ String local = HlsUriRewrite.rewritePlaylist(filtered, "http://localhost:9/playlist", "sess");
+ var hls = new HlsPlaylistState("mlhls://localhost/master.m3u8", "http://localhost:9/playlist/master.m3u8?session=sess");
+ hls.storeMasterPlaylist(local, remoteUris);
+
+ assertTrue(hls.pendingMediaUriCount() >= 1);
+ assertTrue(hls.getPlaylist("mlhls://localhost/master.m3u8").contains("http://localhost:9/playlist"));
+ assertFalse(hls.getPlaylist("mlhls://localhost/master.m3u8").contains("vp09"));
+ String first = hls.nextMediaUri();
+ assertTrue(first.startsWith("mlhls://"), "FCUP URI must stay mlhls, got " + first);
+ assertTrue(first.contains("/itag/136/"), "video itags must prefetch before audio, got " + first);
+ assertFalse(hls.hasCachedVideoMedia());
+ hls.putPlaylist("mlhls://localhost/itag/136/mediadata.m3u8",
+ "#EXTM3U\n#EXTINF:6.0,\nv.ts\n#EXT-X-ENDLIST\n");
+ assertTrue(hls.hasCachedVideoMedia());
+ assertEquals(1, hls.videoMediaUriCount());
+ }
+
+ @Test
+ void scrubLatchIgnoresPauseUntilRateOne() {
+ var hls = new HlsPlaylistState("mlhls://localhost/master.m3u8", "http://localhost/playlist/master.m3u8?session=x");
+ assertFalse(hls.shouldIgnorePause());
+ hls.markScrubIgnorePauseUntilPlay();
+ assertTrue(hls.shouldIgnorePause());
+ hls.clearScrubIgnorePause();
+ assertFalse(hls.shouldIgnorePause());
+ }
+
+ @Test
+ void isLikelyVideoMediaUriFiltersAudioAlts() {
+ assertTrue(HlsPlaylistState.isLikelyVideoMediaUri("mlhls://localhost/itag/229/mediadata.m3u8"));
+ assertFalse(HlsPlaylistState.isLikelyVideoMediaUri("mlhls://localhost/itag/233/mediadata.m3u8"));
+ assertFalse(HlsPlaylistState.isLikelyVideoMediaUri(
+ "mlhls://localhost/itag/233/xtags/abc/mediadata.m3u8"));
+ }
+
+ @Test
+ void sumMediaDurationSecondsFromExtinf() {
+ String media = """
+ #EXTM3U
+ #EXT-X-TARGETDURATION:6
+ #EXTINF:5.5,
+ a.ts
+ #EXTINF:4.5,
+ b.ts
+ #EXT-X-ENDLIST
+ """;
+ assertEquals(10.0, HlsPlaylistState.sumMediaDurationSeconds(media), 0.001);
+ }
+
+ @Test
+ void postEosMediaRefreshDetectsChangedMediadata() {
+ var hls = new HlsPlaylistState("mlhls://localhost/master.m3u8", "http://localhost/playlist/master.m3u8?session=x");
+ String uri = "mlhls://localhost/itag/229/mediadata.m3u8";
+ hls.putPlaylist(uri, "#EXTM3U\n#EXTINF:6.0,\nad.ts\n#EXT-X-ENDLIST\n");
+ hls.beginPostEosMediaRefresh(List.of(uri));
+ assertTrue(hls.isPostEosMediaRefreshing());
+ assertEquals(uri, hls.nextMediaUri());
+ hls.putPlaylist(uri, "#EXTM3U\n#EXTINF:6.0,\nad.ts\n#EXT-X-ENDLIST\n");
+ assertFalse(hls.finishPostEosMediaRefresh());
+
+ hls.putPlaylist(uri, "#EXTM3U\n#EXTINF:6.0,\nad.ts\n#EXT-X-ENDLIST\n");
+ hls.beginPostEosMediaRefresh(List.of(uri));
+ hls.nextMediaUri();
+ hls.putPlaylist(uri, "#EXTM3U\n#EXTINF:30.0,\ncontent.ts\n#EXT-X-ENDLIST\n");
+ assertTrue(hls.finishPostEosMediaRefresh());
+ }
+
+ @Test
+ void mediaDurationIgnoresLiveWindowAndTakesMaxEndListVod() {
+ var hls = new HlsPlaylistState("mlhls://localhost/master.m3u8", "http://localhost/playlist/master.m3u8?session=x");
+ hls.putPlaylist("mlhls://localhost/itag/229/mediadata.m3u8",
+ "#EXTM3U\n#EXTINF:15.0,\nad.ts\n#EXT-X-ENDLIST\n");
+ assertEquals(15.0, hls.getMediaDurationSeconds(), 0.001);
+ assertFalse(hls.isLivePlaylist());
+ // Sliding window without ENDLIST must not inflate past the ad VOD length.
+ hls.putPlaylist("mlhls://localhost/itag/230/mediadata.m3u8",
+ "#EXTM3U\n#EXTINF:3600.0,\nlive.ts\n");
+ assertTrue(hls.isLivePlaylist());
+ assertEquals(0.0, hls.getMediaDurationSeconds(), 0.001);
+ }
+
+ @Test
+ void shouldRefreshPlaylistRateLimits() {
+ var hls = new HlsPlaylistState("mlhls://localhost/master.m3u8", "http://localhost/playlist/master.m3u8?session=x");
+ String uri = "mlhls://localhost/itag/229/mediadata.m3u8";
+ assertTrue(hls.shouldRefreshPlaylist(uri, 1_000_000_000L));
+ assertFalse(hls.shouldRefreshPlaylist(uri, 1_000_000_000L));
+ }
+
+ @Test
+ void postEosMediaSweepFlag() {
+ var hls = new HlsPlaylistState("mlhls://localhost/master.m3u8", "http://localhost/playlist/master.m3u8?session=x");
+ hls.resetPostEosMediaSweep();
+ assertFalse(hls.isPostEosMediaSweepDone());
+ hls.markPostEosMediaSweepDone();
+ assertTrue(hls.isPostEosMediaSweepDone());
+ hls.setWaitingForMasterChange(false);
+ assertFalse(hls.isPostEosMediaSweepDone());
+ assertEquals(0, hls.getPostEosMasterPollCount());
+ }
+
+ @Test
+ void noteMasterPollTriggersMediaResweepEveryNthPoll() {
+ var hls = new HlsPlaylistState("mlhls://localhost/master.m3u8", "http://localhost/playlist/master.m3u8?session=x");
+ hls.setWaitingForMasterChange(true);
+ hls.markPostEosMediaSweepDone();
+
+ assertFalse(hls.noteMasterPollAndShouldResweepMedia(3));
+ assertTrue(hls.isPostEosMediaSweepDone());
+ assertFalse(hls.noteMasterPollAndShouldResweepMedia(3));
+ assertTrue(hls.isPostEosMediaSweepDone());
+ assertTrue(hls.noteMasterPollAndShouldResweepMedia(3), "3rd poll should arm another mediadata sweep");
+ assertFalse(hls.isPostEosMediaSweepDone());
+ assertEquals(3, hls.getPostEosMasterPollCount());
+
+ hls.markPostEosMediaSweepDone();
+ assertFalse(hls.noteMasterPollAndShouldResweepMedia(3));
+ assertFalse(hls.noteMasterPollAndShouldResweepMedia(3));
+ assertTrue(hls.noteMasterPollAndShouldResweepMedia(3));
+ }
+
+ @Test
+ void noteMasterPollResweepDisabledWhenEveryNNonPositive() {
+ var hls = new HlsPlaylistState("mlhls://localhost/master.m3u8", "http://localhost/playlist/master.m3u8?session=x");
+ hls.markPostEosMediaSweepDone();
+ assertFalse(hls.noteMasterPollAndShouldResweepMedia(0));
+ assertFalse(hls.noteMasterPollAndShouldResweepMedia(-1));
+ assertTrue(hls.isPostEosMediaSweepDone());
+ }
+
+ @Test
+ void postEosResweepThenChangedMediadataRestartsCycle() {
+ // Dump 20260918-034535: ad VOD ENDLIST ~6s, master URI list stable, mediadata must change.
+ var hls = new HlsPlaylistState("mlhls://localhost/master.m3u8", "http://localhost/playlist/master.m3u8?session=x");
+ String uri = "mlhls://localhost/itag/232/mediadata.m3u8";
+ String ad = "#EXTM3U\n#EXTINF:6.0,\nad.ts\n#EXT-X-ENDLIST\n";
+ String content = "#EXTM3U\n#EXTINF:10.0,\ncontent.ts\n#EXTINF:10.0,\nc2.ts\n#EXT-X-ENDLIST\n";
+ hls.putPlaylist(uri, ad);
+ hls.setWaitingForMasterChange(true);
+ hls.resetPostEosMediaSweep();
+
+ hls.beginPostEosMediaRefresh(List.of(uri));
+ hls.putPlaylist(uri, ad);
+ assertFalse(hls.finishPostEosMediaRefresh());
+ hls.markPostEosMediaSweepDone();
+
+ assertFalse(hls.noteMasterPollAndShouldResweepMedia(3));
+ assertFalse(hls.noteMasterPollAndShouldResweepMedia(3));
+ assertTrue(hls.noteMasterPollAndShouldResweepMedia(3));
+
+ hls.beginPostEosMediaRefresh(List.of(uri));
+ hls.putPlaylist(uri, content);
+ assertTrue(hls.finishPostEosMediaRefresh());
+ }
+
+ @Test
+ void actionAtItemEndDefaultsToPause() {
+ var hls = new HlsPlaylistState("mlhls://localhost/master.m3u8", "http://localhost/playlist/master.m3u8?session=x");
+ assertEquals(1, hls.getActionAtItemEnd());
+ hls.setActionAtItemEnd(0);
+ assertEquals(0, hls.getActionAtItemEnd());
+ }
+
+ @Test
+ void mediaDurationFrozenWhileWaitingForPlaylistRemove() {
+ var hls = new HlsPlaylistState("mlhls://localhost/master.m3u8", "http://localhost/playlist/master.m3u8?session=x");
+ String shortAd = "#EXTM3U\n#EXTINF:7.04,\nad.ts\n#EXT-X-ENDLIST\n";
+ String longer = "#EXTM3U\n#EXTINF:15.6,\nnext.ts\n#EXT-X-ENDLIST\n";
+ hls.putPlaylist("mlhls://localhost/itag/229/mediadata.m3u8", shortAd);
+ assertEquals(7.04, hls.getMediaDurationSeconds(), 0.001);
+
+ hls.setWaitingForMasterChange(true);
+ hls.putPlaylist("mlhls://localhost/itag/229/mediadata.m3u8", longer);
+ assertEquals(7.04, hls.getMediaDurationSeconds(), 0.001);
+
+ hls.setWaitingForMasterChange(false);
+ hls.putPlaylist("mlhls://localhost/itag/229/mediadata.m3u8", longer);
+ assertEquals(15.6, hls.getMediaDurationSeconds(), 0.001);
+ }
+
+ @Test
+ void recordRawMasterIfChangedIgnoresIdenticalBodies() {
+ var hls = new HlsPlaylistState("mlhls://localhost/master.m3u8", "http://localhost/playlist/master.m3u8?session=x");
+ assertTrue(hls.recordRawMasterIfChanged("raw-v1"));
+ assertFalse(hls.recordRawMasterIfChanged("raw-v1"));
+ assertTrue(hls.recordRawMasterIfChanged("raw-v2"));
+ }
}
diff --git a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewriteTest.java b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewriteTest.java
index 1db9e05f..28ebce9b 100644
--- a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewriteTest.java
+++ b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/session/HlsUriRewriteTest.java
@@ -5,11 +5,20 @@
import java.util.Base64;
import java.util.List;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class HlsUriRewriteTest {
+ @Test
+ void absoluteHttpContentLocationPassesThrough() {
+ String http = "http://127.0.0.1:18765/ad1/index.m3u8";
+ String https = "https://cdn.example/vod.m3u8?token=1";
+ assertEquals(http, HlsUriRewrite.toLocalUri(http, "http://127.0.0.1:9/playlist", "sess"));
+ assertEquals(https, HlsUriRewrite.toLocalUri(https, "http://127.0.0.1:9/playlist", "sess"));
+ }
+
@Test
void rewriteMasterPlaylistWithXtagsPaths() {
String master = """
@@ -26,6 +35,30 @@ void rewriteMasterPlaylistWithXtagsPaths() {
assertFalse(rewritten.contains("mlhls://"));
}
+ @Test
+ void preferAvcVariantsDropsVp9AndSubtitles() {
+ String master = """
+ #EXTM3U
+ #EXT-X-INDEPENDENT-SEGMENTS
+ #EXT-X-MEDIA:URI="http://localhost/a233.m3u8",TYPE=AUDIO,GROUP-ID="233",NAME="he-aac"
+ #EXT-X-MEDIA:URI="http://localhost/a234.m3u8",TYPE=AUDIO,GROUP-ID="234",NAME="aac-lc"
+ #EXT-X-MEDIA:URI="https://example/subs.m3u8",TYPE=SUBTITLES,GROUP-ID="vtt",NAME="en"
+ #EXT-X-STREAM-INF:BANDWIDTH=1000,CODECS="avc1.4D401E,mp4a.40.2",RESOLUTION=640x360,AUDIO="234"
+ http://localhost/avc.m3u8
+ #EXT-X-STREAM-INF:BANDWIDTH=2000,CODECS="vp09.00.31.08,mp4a.40.2",RESOLUTION=1280x720,AUDIO="234"
+ http://localhost/vp9.m3u8
+ """;
+
+ String filtered = HlsUriRewrite.preferAvcVariants(master);
+
+ assertTrue(filtered.contains("avc.m3u8"));
+ assertTrue(filtered.contains("GROUP-ID=\"234\""));
+ assertFalse(filtered.contains("vp9.m3u8"));
+ assertFalse(filtered.contains("TYPE=SUBTITLES"));
+ // AUDIO 233 unused by remaining AVC variants
+ assertFalse(filtered.contains("GROUP-ID=\"233\""));
+ }
+
@Test
void extractMediaUrisFromSampleMasterPlaylist() throws Exception {
var listBase64 = "I0VYVE0zVQojRVhULVgtSU5ERVBFTkRFTlQtU0VHTUVOVFMKI0VYVC1YLU1FRElBOlVSST0ibWxobHM6Ly9sb2NhbGhvc3QvaXRhZy8yMzMvbWVkaWFkYXRhLm0zdTgiLFRZUEU9QVVESU8sR1JPVVAtSUQ9IjIzMyIsREVGQVVMVD1ZRVMsQVVUT1NFTEVDVD1ZRVMsTkFNRT0iRGVmYXVsdCIKI0VYVC1YLU1FRElBOlVSST0ibWxobHM6Ly9sb2NhbGhvc3QvaXRhZy8yMzQvbWVkaWFkYXRhLm0zdTgiLFRZUEU9QVVESU8sR1JPVVAtSUQ9IjIzNCIsREVGQVVMVD1ZRVMsQVVUT1NFTEVDVD1ZRVMsTkFNRT0iRGVmYXVsdCIKI0VYVC1YLVNUUkVBTS1JTkY6QkFORFdJRFRIPTEyMDk4NjIsQ09ERUNTPSJhdmMxLjRENDAxRSxtcDRhLjQwLjIiLFJFU09MVVRJT049NjQweDM2MCxBVURJTz0iMjM0IixGUkFNRS1SQVRFPTMwLFZJREVPLVJBTkdFPVNEUixDTE9TRUQtQ0FQVElPTlM9Tk9ORQptbGhsczovL2xvY2FsaG9zdC9pdGFnLzIzMC9tZWRpYWRhdGEubTN1OAojRVhULVgtU1RSRUFNLUlORjpCQU5EV0lEVEg9NTQ2MjM5LENPREVDUz0iYXZjMS40RDQwMTUsbXA0YS40MC41IixSRVNPTFVUSU9OPTQyNngyNDAsQVVESU89IjIzMyIsRlJBTUUtUkFURT0zMCxWSURFTy1SQU5HRT1TRFIsQ0xPU0VELUNBUFRJT05TPU5PTkUKbWxobHM6Ly9sb2NhbGhvc3QvaXRhZy8yMjkvbWVkaWFkYXRhLm0zdTgKI0VYVC1YLVNUUkVBTS1JTkY6QkFORFdJRFRIPTYzMDIzOSxDT0RFQ1M9ImF2YzEuNEQ0MDE1LG1wNGEuNDAuMiIsUkVTT0xVVElPTj00MjZ4MjQwLEFVRElPPSIyMzQiLEZSQU1FLVJBVEU9MzAsVklERU8tUkFOR0U9U0RSLENMT1NFRC1DQVBUSU9OUz1OT05FCm1saGxzOi8vbG9jYWxob3N0L2l0YWcvMjI5L21lZGlhZGF0YS5tM3U4CiNFWFQtWC1TVFJFQU0tSU5GOkJBTkRXSURUSD0xNTY4NzI2LENPREVDUz0iYXZjMS40RDQwMUYsbXA0YS40MC4yIixSRVNPTFVUSU9OPTE5MjB4MTA4MCxBVURJTy0iMjM0IixGUkFNRS1SQVRFPTYwLFZJREVPLVJBTkdFPVNEUixDTE9TRUQtQ0FQVElPTlM9Tk9ORQptbGhsczovL2xvY2FsaG9zdC9pdGFnLzMxMS9tZWRpYWRhdGEubTN1OAo=";
diff --git a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/util/AirPlayVolumeTest.java b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/util/AirPlayVolumeTest.java
new file mode 100644
index 00000000..7b42271c
--- /dev/null
+++ b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/util/AirPlayVolumeTest.java
@@ -0,0 +1,31 @@
+package com.github.serezhka.airplay.server.internal.handler.util;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AirPlayVolumeTest {
+
+ @Test
+ void maxDbIsFullLinear() {
+ assertEquals(1.0, AirPlayVolume.fromDecibels(0.0), 1e-9);
+ }
+
+ @Test
+ void muteDbIsZero() {
+ assertEquals(0.0, AirPlayVolume.fromDecibels(-144.0), 1e-9);
+ }
+
+ @Test
+ void roundTripNearUnity() {
+ double linear = AirPlayVolume.fromDecibels(-6.0);
+ assertTrue(linear > 0.4 && linear < 0.6);
+ assertEquals(-6.0, AirPlayVolume.toDecibels(linear), 0.05);
+ }
+
+ @Test
+ void rtspFormatUsesDb() {
+ assertTrue(AirPlayVolume.formatRtspParameter(1.0).startsWith("volume: 0.000000"));
+ }
+}
diff --git a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtilPlaybackInfoTest.java b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtilPlaybackInfoTest.java
new file mode 100644
index 00000000..35e2d4f7
--- /dev/null
+++ b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/util/PropertyListUtilPlaybackInfoTest.java
@@ -0,0 +1,51 @@
+package com.github.serezhka.airplay.server.internal.handler.util;
+
+import com.dd.plist.NSDictionary;
+import com.dd.plist.PropertyListParser;
+import com.github.serezhka.airplay.server.Playback;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PropertyListUtilPlaybackInfoTest {
+
+ @Test
+ void readyToPlayWhenDurationKnown() throws Exception {
+ byte[] xml = PropertyListUtil.preparePlaybackInfoResponse(new Playback.Info(120, 15, 1));
+ NSDictionary dict = (NSDictionary) PropertyListParser.parse(xml);
+ assertEquals(120.0, ((Number) dict.get("duration").toJavaObject()).doubleValue(), 0.001);
+ assertEquals(15.0, ((Number) dict.get("position").toJavaObject()).doubleValue(), 0.001);
+ assertEquals(1, ((Number) dict.get("rate").toJavaObject()).intValue());
+ assertTrue((Boolean) dict.get("readyToPlay").toJavaObject());
+ assertTrue((Boolean) dict.get("playbackBufferEmpty").toJavaObject());
+ assertFalse((Boolean) dict.get("playbackBufferFull").toJavaObject());
+ assertTrue((Boolean) dict.get("playbackLikelyToKeepUp").toJavaObject());
+ }
+
+ @Test
+ void keepsCallerRateAtEnd() throws Exception {
+ // VOD ad EOS: ControlHandler pins rate=1 at position=duration; must not be zeroed here.
+ byte[] xml = PropertyListUtil.preparePlaybackInfoResponse(new Playback.Info(6, 6, 1));
+ NSDictionary dict = (NSDictionary) PropertyListParser.parse(xml);
+ assertEquals(1, ((Number) dict.get("rate").toJavaObject()).intValue());
+ assertEquals(6.0, ((Number) dict.get("position").toJavaObject()).doubleValue(), 0.001);
+ }
+
+ @Test
+ void reportsPausedRate() throws Exception {
+ byte[] xml = PropertyListUtil.preparePlaybackInfoResponse(new Playback.Info(60, 10, 0));
+ NSDictionary dict = (NSDictionary) PropertyListParser.parse(xml);
+ assertEquals(0, ((Number) dict.get("rate").toJavaObject()).intValue());
+ assertTrue((Boolean) dict.get("readyToPlay").toJavaObject());
+ }
+
+ @Test
+ void spinnerFlagsWhenDurationUnknown() throws Exception {
+ byte[] xml = PropertyListUtil.preparePlaybackInfoResponse(new Playback.Info(0, 0, 1));
+ NSDictionary dict = (NSDictionary) PropertyListParser.parse(xml);
+ assertFalse((Boolean) dict.get("readyToPlay").toJavaObject());
+ assertTrue((Boolean) dict.get("playbackBufferEmpty").toJavaObject());
+ }
+}
diff --git a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/video/VideoHandlerNalTest.java b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/video/VideoHandlerNalTest.java
index ceeedd9b..dbafa96f 100644
--- a/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/video/VideoHandlerNalTest.java
+++ b/server/src/test/java/com/github/serezhka/airplay/server/internal/handler/video/VideoHandlerNalTest.java
@@ -3,6 +3,8 @@
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
class VideoHandlerNalTest {
@@ -14,7 +16,7 @@ void convertsEveryAvccNalToAnnexB() {
0, 0, 0, 1, 0x65
};
- VideoHandler.toAnnexB(payload);
+ assertTrue(VideoHandler.toAnnexB(payload));
assertArrayEquals(new byte[]{
0, 0, 0, 1, 0x67, 0x42,
@@ -22,4 +24,10 @@ void convertsEveryAvccNalToAnnexB() {
0, 0, 0, 1, 0x65
}, payload);
}
+
+ @Test
+ void rejectsCorruptLengthPrefixedNal() {
+ byte[] payload = {0, 0, 0, 10, 0x65};
+ assertFalse(VideoHandler.toAnnexB(payload));
+ }
}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 44fa5470..50d4d313 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -21,12 +21,11 @@ rootProject.name = "java-airplay"
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
-include("lib")
+include("protocol")
+include("player:support")
include("server")
-include("client")
include("player:app")
include("player:gstreamer")
include("player:dump")
-include("player:vlc")
include("player:ffmpeg")
include("player:harness")