#include "cast_media.h" extern "C" { #include #include #include #include } #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include static void ok(int result) { assert(result >= 0); } static std::vector readFile(const std::string &path) { std::ifstream file(path, std::ios::binary); assert(file.good()); return {std::istreambuf_iterator(file), std::istreambuf_iterator()}; } static void writeFile(const std::string &path, const std::vector &bytes) { std::ofstream file(path, std::ios::binary); file.write(reinterpret_cast(bytes.data()), bytes.size()); assert(file.good()); } // Real source A/V built with the same bundled encoders; no PATH ffmpeg or fixtures. static void fixture(const std::string &path) { AVFormatContext *format = nullptr; ok(avformat_alloc_output_context2(&format, nullptr, "mpegts", path.c_str())); AVCodecContext *video = avcodec_alloc_context3(avcodec_find_encoder_by_name("h264_videotoolbox")); AVCodecContext *audio = avcodec_alloc_context3(avcodec_find_encoder(AV_CODEC_ID_AAC)); assert(video && audio); video->width = 96; video->height = 64; video->pix_fmt = AV_PIX_FMT_NV12; video->time_base = {1, 10}; video->framerate = {10, 1}; video->bit_rate = 300000; video->gop_size = 70; video->max_b_frames = 0; video->sample_aspect_ratio = {4, 3}; av_opt_set(video->priv_data, "allow_sw", "1", 0); av_opt_set(video->priv_data, "realtime", "1", 0); audio->sample_rate = 48000; audio->sample_fmt = AV_SAMPLE_FMT_FLTP; audio->time_base = {1, 48000}; audio->bit_rate = 128000; av_channel_layout_default(&audio->ch_layout, 2); ok(avcodec_open2(video, video->codec, nullptr)); ok(avcodec_open2(audio, audio->codec, nullptr)); auto *vs = avformat_new_stream(format, nullptr), *as = avformat_new_stream(format, nullptr); assert(vs && as); vs->time_base = video->time_base; as->time_base = audio->time_base; ok(avcodec_parameters_from_context(vs->codecpar, video)); ok(avcodec_parameters_from_context(as->codecpar, audio)); AVBSFContext *aspect = nullptr; ok(av_bsf_alloc(av_bsf_get_by_name("h264_metadata"), &aspect)); ok(avcodec_parameters_copy(aspect->par_in, vs->codecpar)); aspect->time_base_in = video->time_base; ok(av_opt_set(aspect->priv_data, "sample_aspect_ratio", "4/3", 0)); ok(av_bsf_init(aspect)); ok(avio_open(&format->pb, path.c_str(), AVIO_FLAG_WRITE)); ok(avformat_write_header(format, nullptr)); auto encode = [&](AVCodecContext *codec, AVStream *stream, AVFrame *frame) { ok(avcodec_send_frame(codec, frame)); auto *packet = av_packet_alloc(); assert(packet); for (;;) { int result = avcodec_receive_packet(codec, packet); if (result == AVERROR(EAGAIN) || result == AVERROR_EOF) break; ok(result); if (codec == video) { ok(av_bsf_send_packet(aspect, packet)); while (av_bsf_receive_packet(aspect, packet) >= 0) { av_packet_rescale_ts(packet, codec->time_base, stream->time_base); packet->stream_index = stream->index; ok(av_interleaved_write_frame(format, packet)); av_packet_unref(packet); } } else { av_packet_rescale_ts(packet, codec->time_base, stream->time_base); packet->stream_index = stream->index; ok(av_interleaved_write_frame(format, packet)); av_packet_unref(packet); } } av_packet_free(&packet); }; int64_t vi = 0, ai = 0; while (vi < 900 || ai < 90 * 48000) { bool isVideo = vi < 900 && (ai >= 90 * 48000 || vi / 10.0 <= ai / 48000.0); auto *f = av_frame_alloc(); assert(f); if (isVideo) { f->width = 96; f->height = 64; f->format = video->pix_fmt; f->pts = vi++; ok(av_frame_get_buffer(f, 32)); for (int y = 0; y < 64; ++y) std::memset(f->data[0] + y * f->linesize[0], 40 + vi % 160, 96); for (int y = 0; y < 32; ++y) std::memset(f->data[1] + y * f->linesize[1], 128, 96); encode(video, vs, f); } else { f->sample_rate = 48000; f->format = audio->sample_fmt; f->nb_samples = audio->frame_size; ok(av_channel_layout_copy(&f->ch_layout, &audio->ch_layout)); f->pts = ai; ok(av_frame_get_buffer(f, 0)); for (int channel = 0; channel < 2; ++channel) for (int n = 0; n < f->nb_samples; ++n) reinterpret_cast(f->data[channel])[n] = float(.1 * std::sin((ai + n) * 440 * 2 * 3.141592653589793 / 48000)); ai += f->nb_samples; encode(audio, as, f); } av_frame_free(&f); } encode(video, vs, nullptr); encode(audio, as, nullptr); ok(av_write_trailer(format)); av_bsf_free(&aspect); avio_closep(&format->pb); avformat_free_context(format); avcodec_free_context(&video); avcodec_free_context(&audio); } static std::vector wav() { std::vector bytes(44 + 90 * 48000 * 2); auto put = [&](size_t at, uint32_t value, int length) { for (int i = 0; i < length; ++i) bytes[at + i] = uint8_t(value >> (8 * i)); }; std::memcpy(bytes.data(), "RIFF", 4); put(4, uint32_t(bytes.size() - 8), 4); std::memcpy(bytes.data() + 8, "WAVEfmt ", 8); put(16, 16, 4); put(20, 1, 2); put(22, 1, 2); put(24, 48000, 4); put(28, 96000, 4); put(32, 2, 2); put(34, 16, 2); std::memcpy(bytes.data() + 36, "data", 4); put(40, uint32_t(bytes.size() - 44), 4); for (size_t i = 0; i < 90 * 48000; ++i) put(44 + i * 2, uint16_t(int16_t(4000 * std::sin(i * 440 * 2 * 3.141592653589793 / 48000))), 2); return bytes; } struct HTTP { int status = 0; std::string headers; std::vector bytes; }; static HTTP request(const std::string &url, const std::string &method = "GET", const std::string &headers = "") { assert(url.starts_with("http://127.0.0.1:")); size_t slash = url.find('/', 7), colon = url.find(':', 7); sockaddr_in address{}; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); address.sin_port = htons(std::stoi(url.substr(colon + 1, slash - colon - 1))); int socket = ::socket(AF_INET, SOCK_STREAM, 0); assert(socket >= 0); timeval timeout{60, 0}; setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); assert(connect(socket, reinterpret_cast(&address), sizeof(address)) == 0); std::string text = method + " " + url.substr(slash) + " HTTP/1.1\r\nHost: 127.0.0.1\r\n" + headers + "Connection: close\r\n\r\n"; assert(send(socket, text.data(), text.size(), 0) == ssize_t(text.size())); std::vector bytes; uint8_t buffer[65536]; for (;;) { ssize_t n = recv(socket, buffer, sizeof(buffer), 0); assert(n >= 0); if (!n) break; bytes.insert(bytes.end(), buffer, buffer + n); } close(socket); std::string raw(bytes.begin(), bytes.end()); size_t end = raw.find("\r\n\r\n"); assert(end != std::string::npos); return {std::stoi(raw.substr(9, 3)), raw.substr(0, end + 4), {bytes.begin() + end + 4, bytes.end()}}; } // A tiny real upstream tests resource-specific private headers and byte-range seek. struct Upstream { int listener; uint16_t port; std::thread threads[4]; std::atomic stopped{false}; std::atomic foreignRequests{0}, videoRequests{0}, audioRequests{0}; std::map> resources; std::string foreign; explicit Upstream(std::vector video, std::vector audio) { resources["/source.ts"] = std::move(video); resources["/audio.wav"] = std::move(audio); resources["/public.ts"] = resources["/source.ts"]; listener = socket(AF_INET, SOCK_STREAM, 0); assert(listener >= 0); int one = 1; setsockopt(listener, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one)); sockaddr_in address{}; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); assert(bind(listener, reinterpret_cast(&address), sizeof(address)) == 0); assert(listen(listener, 16) == 0); assert(fcntl(listener, F_SETFL, O_NONBLOCK) == 0); socklen_t length = sizeof(address); assert(getsockname(listener, reinterpret_cast(&address), &length) == 0); port = ntohs(address.sin_port); } std::string url(std::string path) const { return "http://127.0.0.1:" + std::to_string(port) + path; } void start() { for (auto &thread : threads) thread = std::thread([&] { while (!stopped) { pollfd ready{listener, POLLIN, 0}; if (poll(&ready, 1, 100) <= 0 || !(ready.revents & POLLIN)) continue; int client = accept(listener, nullptr, nullptr); if (client < 0) continue; assert(fcntl(client, F_SETFL, fcntl(client, F_GETFL) & ~O_NONBLOCK) == 0); int one = 1; setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one)); timeval timeout{5, 0}; setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); std::string request; char buffer[2048]; while (request.find("\r\n\r\n") == std::string::npos && request.size() < 16384) { auto n = recv(client, buffer, sizeof(buffer), 0); if (n <= 0) break; request.append(buffer, size_t(n)); } size_t begin = request.find(' '), end = request.find(' ', begin + 1); std::string path = request.substr(begin + 1, end - begin - 1); int status = 200; std::string extra; std::vector data; if (path == "/redirect") { status = 302; extra = "Location: " + foreign + "/source.ts\r\n"; } else if (path == "/cross.m3u8" || path == "/index.m3u8" || path == "/public-cross.m3u8") { std::string playlist = "#EXTM3U\n#EXT-X-TARGETDURATION:90\n#EXTINF:90,\n" + (path == "/index.m3u8" ? url("") : foreign) + (path == "/public-cross.m3u8" ? "/public.ts" : "/source.ts") + "\n#EXT-X-ENDLIST\n"; data.assign(playlist.begin(), playlist.end()); } else if (auto resource = resources.find(path); resource != resources.end()) { bool audio = path == "/audio.wav"; bool publicMedia = path == "/public.ts"; bool authorized = publicMedia || request.find(audio ? "X-Audio: audio-secret" : "X-Video: video-secret") != std::string::npos; bool wrong = request.find(publicMedia ? "secret" : (audio ? "video-secret" : "audio-secret")) != std::string::npos; if (!authorized || wrong) { ++foreignRequests; status = 403; } else { if (audio) ++audioRequests; else ++videoRequests; data = resource->second; } } else status = 404; size_t low = 0, high = data.size(); auto range = request.find("Range: bytes="); if (range != std::string::npos && !data.empty()) { low = std::stoull(request.substr(range + 13)); auto dash = request.find('-', range); if (dash != std::string::npos && std::isdigit(request[dash + 1])) high = std::min(high, size_t(std::stoull(request.substr(dash + 1))) + 1); if (low >= high) { status = 416; low = high = 0; } else { status = 206; extra += "Content-Range: bytes " + std::to_string(low) + "-" + std::to_string(high - 1) + "/" + std::to_string(data.size()) + "\r\n"; } } std::string header = "HTTP/1.1 " + std::to_string(status) + " Test\r\nConnection: close\r\nAccept-Ranges: bytes\r\n" + extra + "Content-Length: " + std::to_string(high - low) + "\r\n\r\n"; send(client, header.data(), header.size(), 0); if (!request.starts_with("HEAD")) while (high > low) { auto n = send(client, data.data() + low, high - low, 0); if (n <= 0) break; low += size_t(n); } close(client); } }); } ~Upstream() { stopped = true; shutdown(listener, SHUT_RDWR); for (auto &thread : threads) if (thread.joinable()) thread.join(); close(listener); } }; static void playable(const HTTP &response, const std::string &path, bool video, double minimumTime) { assert(response.status == 200 && response.bytes.size() > 188 && response.bytes[0] == 0x47); writeFile(path, response.bytes); AVFormatContext *format = nullptr; ok(avformat_open_input(&format, path.c_str(), nullptr, nullptr)); ok(avformat_find_stream_info(format, nullptr)); int audioIndex = av_find_best_stream(format, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); assert(audioIndex >= 0); int videoIndex = av_find_best_stream(format, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); assert(!video || videoIndex >= 0); std::map codecs; std::map frames; for (int index : {audioIndex, videoIndex}) if (index >= 0) { auto *stream = format->streams[index]; auto *codec = avcodec_alloc_context3(avcodec_find_decoder(stream->codecpar->codec_id)); assert(codec); ok(avcodec_parameters_to_context(codec, stream->codecpar)); ok(avcodec_open2(codec, codec->codec, nullptr)); codecs[index] = codec; } auto *packet = av_packet_alloc(); auto *frame = av_frame_alloc(); bool timestamp = false; while (av_read_frame(format, packet) >= 0) { auto found = codecs.find(packet->stream_index); if (found != codecs.end()) { if (packet->pts != AV_NOPTS_VALUE) { double time = packet->pts * av_q2d(format->streams[packet->stream_index]->time_base); assert(time >= minimumTime - .1); timestamp = true; } ok(avcodec_send_packet(found->second, packet)); while (avcodec_receive_frame(found->second, frame) >= 0) { if (packet->stream_index == videoIndex) { auto sar = av_guess_sample_aspect_ratio(format, format->streams[videoIndex], frame); if (sar.num <= 0 || sar.den <= 0) sar = {1, 1}; assert(sar.num > 0 && std::abs(double(frame->width) / frame->height * av_q2d(sar) - 2.0) < .01); } ++frames[packet->stream_index]; av_frame_unref(frame); } } av_packet_unref(packet); } assert(timestamp && frames[audioIndex] > 10 && (!video || frames[videoIndex] > 10)); for (auto &[index, codec] : codecs) avcodec_free_context(&codec); av_packet_free(&packet); av_frame_free(&frame); avformat_close_input(&format); } static std::pair audioSpan(const HTTP &response, const std::string &path) { assert(response.status == 200); writeFile(path, response.bytes); AVFormatContext *format = nullptr; ok(avformat_open_input(&format, path.c_str(), nullptr, nullptr)); ok(avformat_find_stream_info(format, nullptr)); int index = av_find_best_stream(format, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); assert(index >= 0); auto *codec = avcodec_alloc_context3(avcodec_find_decoder(format->streams[index]->codecpar->codec_id)); assert(codec); ok(avcodec_parameters_to_context(codec, format->streams[index]->codecpar)); ok(avcodec_open2(codec, codec->codec, nullptr)); auto *packet = av_packet_alloc(); auto *frame = av_frame_alloc(); double first = INFINITY, end = -INFINITY; while (av_read_frame(format, packet) >= 0) { if (packet->stream_index == index) { ok(avcodec_send_packet(codec, packet)); while (avcodec_receive_frame(codec, frame) >= 0) { assert(frame->best_effort_timestamp != AV_NOPTS_VALUE); double position = frame->best_effort_timestamp * av_q2d(format->streams[index]->time_base); first = std::min(first, position); end = position + double(frame->nb_samples) / frame->sample_rate; av_frame_unref(frame); } } av_packet_unref(packet); } av_packet_free(&packet); av_frame_free(&frame); avcodec_free_context(&codec); avformat_close_input(&format); assert(std::isfinite(first) && end > first); return {first, end}; } static CastMedia *start(CastMediaSource source, std::string &url, double &duration) { char error[512]{}, bytes[512]{}; auto *media = cast_media_create(&source, error); assert(media); assert(cast_media_start(media, "127.0.0.1", bytes, &duration, error)); url = bytes; return media; } int main(int argc, char **argv) { bool liveCheck = false; for (int argument = 1; argument < argc; ++argument) if (std::string(argv[argument]) == "--live") liveCheck = true; char temp[] = "/tmp/terebi-cast-check.XXXXXX"; assert(mkdtemp(temp)); std::filesystem::path directory(temp); const auto video = (directory / "source.ts").string(), audio = (directory / "source.wav").string(), segment = (directory / "segment.ts").string(); fixture(video); writeFile(audio, wav()); auto videoBytes = readFile(video), audioBytes = readFile(audio); Upstream upstream(videoBytes, audioBytes), foreign(videoBytes, audioBytes); upstream.foreign = foreign.url(""); upstream.start(); foreign.start(); std::string url; double duration = 0; CastMediaSource local{video.c_str(), "", nullptr, nullptr, true, false, false, 0}; auto *media = start(local, url, duration); assert(duration >= 89 && duration <= 92); std::string base = url.substr(0, url.rfind('/') + 1); auto playlist = request(url); assert(playlist.status == 200); std::string text(playlist.bytes.begin(), playlist.bytes.end()); assert(text.find("#EXT-X-ENDLIST") != std::string::npos); playable(request(base + "segment/10.ts"), segment, true, 60); playable(request(base + "segment/0.ts"), segment, true, 0); assert(request(base + "../source.ts").status == 404); assert(request(base + "%2e%2e/source.ts").status == 404); assert(request(url.substr(0, url.find('/', 7)) + "/index.m3u8").status == 404); assert(request(base + "segment/999999.ts").status == 404); const char vtt[] = "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHello\n"; char subtitle[512]{}; assert(cast_media_subtitle(media, 1, vtt, sizeof(vtt) - 1, subtitle)); auto subtitles = request(subtitle); assert(subtitles.status == 200 && subtitles.headers.find("Access-Control-Allow-Origin: *") != std::string::npos); auto ranged = request(subtitle, "GET", "Range: bytes=0-5\r\n"); assert(ranged.status == 206 && ranged.bytes.size() == 6); assert(request(subtitle, "HEAD").bytes.empty()); assert(request(subtitle, "OPTIONS").status == 204); assert(request(subtitle, "GET", "Range: bytes=99999-\r\n").status == 416); cast_media_cancel(media); cast_media_destroy(media); // Previously published capabilities expire, including the bound listening socket. auto authority = url.substr(7, url.find('/', 7) - 7); auto colon = authority.find(':'); sockaddr_in closed{}; closed.sin_family = AF_INET; closed.sin_addr.s_addr = htonl(INADDR_LOOPBACK); closed.sin_port = htons(std::stoi(authority.substr(colon + 1))); int socket = ::socket(AF_INET, SOCK_STREAM, 0); assert(connect(socket, reinterpret_cast(&closed), sizeof(closed)) != 0); close(socket); std::string hls = upstream.url("/index.m3u8"); CastMediaSource miruro{hls.c_str(), "X-Video: video-secret\r\n", nullptr, nullptr, false, true, false, 90}; media = start(miruro, url, duration); base = url.substr(0, url.rfind('/') + 1); playable(request(base + "segment/10.ts"), segment, true, 60); cast_media_destroy(media); std::string networkVideo = upstream.url("/source.ts"), networkAudio = upstream.url("/audio.wav"); CastMediaSource youtube{networkVideo.c_str(), "X-Video: video-secret\r\n", networkAudio.c_str(), "X-Audio: audio-secret\r\n", false, false, false, 90}; media = start(youtube, url, duration); base = url.substr(0, url.rfind('/') + 1); playable(request(base + "segment/10.ts"), segment, true, 60); auto vodFirstAudio = audioSpan(request(base + "segment/0.ts"), segment); auto vodSecondAudio = audioSpan(request(base + "segment/1.ts"), segment); assert(std::abs(vodSecondAudio.first - vodFirstAudio.second) < 1.0 / 48000); cast_media_destroy(media); assert(upstream.videoRequests > 0 && upstream.audioRequests > 0 && upstream.foreignRequests == 0); CastMediaSource audioOnly{audio.c_str(), "", nullptr, nullptr, true, false, false, 0}; media = start(audioOnly, url, duration); base = url.substr(0, url.rfind('/') + 1); playable(request(base + "segment/10.ts"), segment, false, 60); cast_media_destroy(media); for (const auto &path : {"/redirect", "/cross.m3u8"}) { auto resource = upstream.url(path); CastMediaSource unsafe{resource.c_str(), "X-Video: video-secret\r\n", nullptr, nullptr, false, true, false, 90}; char error[512]{}, result[512]{}; media = cast_media_create(&unsafe, error); assert(media); assert(!cast_media_start(media, "127.0.0.1", result, &duration, error)); assert(std::string(error).find("secret") == std::string::npos); cast_media_destroy(media); } assert(foreign.foreignRequests == 0 && foreign.videoRequests == 0); auto publicManifest = upstream.url("/public-cross.m3u8"); CastMediaSource publicHLS{publicManifest.c_str(), "X-Video: video-secret\r\n", nullptr, nullptr, false, false, false, 90}; media = start(publicHLS, url, duration); base = url.substr(0, url.rfind('/') + 1); playable(request(base + "segment/0.ts"), segment, true, 0); cast_media_destroy(media); assert(foreign.videoRequests > 0 && foreign.foreignRequests == 0); if (liveCheck) { youtube.live = true; media = start(youtube, url, duration); base = url.substr(0, url.rfind('/') + 1); assert(duration == 0); auto firstAudio = audioSpan(request(base + "segment/0.ts"), segment); auto secondAudio = audioSpan(request(base + "segment/1.ts"), segment); assert(std::abs(secondAudio.first - firstAudio.second) < 1.0 / 48000); #ifdef CAST_MEDIA_CHECK assert(cast_media_check_advance(media, 54)); #else assert(false && "Compile the permanent live check with CAST_MEDIA_CHECK"); #endif auto response = request(url); assert(response.status == 200); std::string text(response.bytes.begin(), response.bytes.end()); auto at = text.find("#EXT-X-MEDIA-SEQUENCE:"); assert(at != std::string::npos); int64_t sequence = std::stoll(text.substr(at + 22)); size_t count = 0, cursor = 0; while ((cursor = text.find("#EXTINF:", cursor)) != std::string::npos) { ++count; cursor += 8; } assert(count == 8 && text.find("#EXT-X-ENDLIST") == std::string::npos); int64_t last = sequence + int64_t(count) - 1; assert(sequence > 0 && request(base + "segment/0.ts").status == 404); playable(request(base + "segment/" + std::to_string(last) + ".ts"), segment, true, 0); cast_media_destroy(media); } std::filesystem::remove_all(directory); std::cout << "cast-media: playable A/V, VOD random seek, separate audio, HLS origin/header policy, subtitles, ranges, capabilities, cancellation" << (liveCheck ? ", bounded live window" : " (use --live for rolling window)") << " passed\n"; }