#define STRVIEW_IMPLEMENTATION
#include "strview.h"
#include "probe.h"
#include <ctype.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
struct ProbeCacheEntry {
struct ProbeCacheEntry* next;
const char* path;
ProbeResult result;
};
static bool probe_error(Error* error, const char* message, const char* path) {
error->line = 0;
snprintf(error->message, sizeof(error->message), message, path);
return false;
}
static char* arena_copy(Arena* arena, const char* text) {
size_t size = strlen(text) + 1;
char* copy = arena_alloc(arena, size);
if (copy != NULL) {
memcpy(copy, text, size);
}
return copy;
}
void probe_cache_init(ProbeCache* cache, ProbeRunner runner, void* context) {
*cache = (ProbeCache){
.arena = arena_create(),
.runner = runner != NULL ? runner : probe_run_ffprobe,
.runner_context = context,
};
}
bool probe_cache_get(ProbeCache* cache, const char* path, ProbeResult* result, Error* error) {
for (ProbeCacheEntry* entry = cache->entries; entry != NULL; entry = entry->next) {
if (sv_equal(sv_from_cstr(entry->path), sv_from_cstr(path))) {
*result = entry->result;
return true;
}
}
ProbeResult probed = {0};
cache->invocation_count++;
if (!cache->runner(path, &probed, cache->runner_context, error)) {
return false;
}
ProbeCacheEntry* entry = arena_alloc(&cache->arena, sizeof(*entry));
char* cached_path = arena_copy(&cache->arena, path);
if (entry == NULL || cached_path == NULL) {
return probe_error(error, "out of memory while caching '%s'", path);
}
*entry = (ProbeCacheEntry){.next = cache->entries, .path = cached_path, .result = probed};
cache->entries = entry;
*result = probed;
return true;
}
void probe_cache_destroy(ProbeCache* cache) {
arena_destroy(&cache->arena);
*cache = (ProbeCache){0};
}
static bool append_bytes(char** buffer, size_t* length, size_t* capacity, const char* data, size_t count) {
if (count > SIZE_MAX - *length - 1) {
return false;
}
size_t needed = *length + count + 1;
if (needed > *capacity) {
size_t new_capacity = *capacity == 0 ? 4096 : *capacity;
while (new_capacity < needed) {
if (new_capacity > SIZE_MAX / 2) {
new_capacity = needed;
break;
}
new_capacity *= 2;
}
char* grown = realloc(*buffer, new_capacity);
if (grown == NULL) {
return false;
}
*buffer = grown;
*capacity = new_capacity;
}
memcpy(*buffer + *length, data, count);
*length += count;
(*buffer)[*length] = '\0';
return true;
}
static bool read_process(int fd, char** output) {
char* buffer = NULL;
size_t length = 0;
size_t capacity = 0;
char chunk[4096];
for (;;) {
ssize_t count = read(fd, chunk, sizeof(chunk));
if (count == 0) {
break;
}
if (count < 0) {
if (errno == EINTR) {
continue;
}
free(buffer);
return false;
}
if (!append_bytes(&buffer, &length, &capacity, chunk, (size_t)count)) {
free(buffer);
return false;
}
}
if (buffer == NULL) {
buffer = calloc(1, 1);
}
*output = buffer;
return buffer != NULL;
}
static bool json_value(strview object, strview key, strview* value) {
bool found = false;
size_t offset = sv_find(object, key, &found);
if (!found) {
return false;
}
sv_chop_left(&object, offset + key.len);
while (object.len != 0 && (sv_space_predicate(object.data[0]) || object.data[0] == ':')) {
sv_chop_left(&object, 1);
}
*value = object;
return object.len != 0;
}
static bool json_number(strview input, double* value) {
sv_trim_left(&input);
if (input.len != 0 && input.data[0] == '"') {
sv_chop_left(&input, 1);
}
size_t length = 0;
while (length < input.len &&
(sv_numeric_predicate(input.data[length]) || input.data[length] == '.' ||
input.data[length] == '-' || input.data[length] == '+' ||
input.data[length] == 'e' || input.data[length] == 'E')) {
length++;
}
if (length == 0 || length >= 128) {
return false;
}
char buffer[128];
memcpy(buffer, input.data, length);
buffer[length] = '\0';
char* after = NULL;
errno = 0;
*value = strtod(buffer, &after);
return errno == 0 && after == buffer + length && *value >= 0;
}
static bool json_string(strview input, char* output, size_t capacity) {
sv_trim_left(&input);
if (input.len == 0 || input.data[0] != '"' || capacity == 0) return false;
size_t written = 0;
for (size_t i = 1; i < input.len && input.data[i] != '"'; i++) {
char character = input.data[i];
if (character == '\\' && i + 1 < input.len) {
character = input.data[++i];
if (character == 'n') character = '\n';
else if (character == 'r') character = '\r';
else if (character == 't') character = '\t';
}
if (written + 1 < capacity) output[written++] = character;
}
output[written] = '\0';
return true;
}
static size_t matching_delimiter(strview input, char open, char close) {
unsigned depth = 0;
bool quoted = false;
bool escaped = false;
for (size_t i = 0; i < input.len; i++) {
char character = input.data[i];
if (quoted) {
if (escaped) escaped = false;
else if (character == '\\') escaped = true;
else if (character == '"') quoted = false;
continue;
}
if (character == '"') quoted = true;
else if (character == open) depth++;
else if (character == close && depth != 0 && --depth == 0) return i;
}
return SIZE_MAX;
}
static SubtitleKind subtitle_kind_for_codec(const char* codec) {
if (strcmp(codec, "ass") == 0 || strcmp(codec, "ssa") == 0) return SUBTITLE_ASS;
if (strcmp(codec, "dvd_subtitle") == 0 || strcmp(codec, "dvb_subtitle") == 0 ||
strcmp(codec, "hdmv_pgs_subtitle") == 0 || strcmp(codec, "xsub") == 0) {
return SUBTITLE_BITMAP;
}
return SUBTITLE_TEXT;
}
static bool json_truthy(strview object, const char* key) {
strview value;
return json_value(object, sv_from_cstr(key), &value) &&
(sv_starts_with(value, svlit("1")) || sv_starts_with(value, svlit("true")));
}
static void parse_stream(strview object, ProbeResult* result) {
strview type_value;
if (!json_value(object, svlit("\"codec_type\""), &type_value)) {
return;
}
bool video = sv_starts_with(type_value, svlit("\"video\""));
bool audio = sv_starts_with(type_value, svlit("\"audio\""));
bool subtitles = sv_starts_with(type_value, svlit("\"subtitle\""));
bool attachment = sv_starts_with(type_value, svlit("\"attachment\""));
if (!video && !audio && !subtitles && !attachment) {
return;
}
double raw_index = 0;
strview index_value;
if (!json_value(object, svlit("\"index\""), &index_value) ||
!json_number(index_value, &raw_index) || raw_index > (double)SIZE_MAX) return;
double duration = 0;
strview duration_value;
bool known = json_value(object, svlit("\"duration\""), &duration_value) &&
json_number(duration_value, &duration);
char codec_name[32] = {0};
strview codec_value;
if (json_value(object, svlit("\"codec_name\""), &codec_value)) {
sv_trim_left(&codec_value);
if (codec_value.len != 0 && codec_value.data[0] == '"') {
sv_chop_left(&codec_value, 1);
size_t length = 0;
while (length < codec_value.len && codec_value.data[length] != '"' &&
length + 1 < sizeof(codec_name)) length++;
memcpy(codec_name, codec_value.data, length);
codec_name[length] = '\0';
}
}
if (attachment) {
if (result->attachment_count < PROBE_ATTACHMENT_LIMIT) {
ProbeAttachment* item = &result->attachments[result->attachment_count++];
item->stream_index = (size_t)raw_index;
strview value;
if (json_value(object, svlit("\"filename\""), &value))
json_string(value, item->filename, sizeof(item->filename));
if (json_value(object, svlit("\"mimetype\""), &value))
json_string(value, item->mimetype, sizeof(item->mimetype));
}
return;
}
ValueType type = video ? TYPE_VIDEO : audio ? TYPE_AUDIO : TYPE_SUBTITLES;
if (result->stream_count < PROBE_STREAM_LIMIT) {
result->streams[result->stream_count++] = (ProbeStream){
.type = type,
.stream_index = (size_t)raw_index,
.duration_known = known,
.duration = duration,
};
memcpy(result->streams[result->stream_count - 1].codec_name, codec_name, sizeof(codec_name));
ProbeStream* stream = &result->streams[result->stream_count - 1];
stream->subtitle_kind = subtitle_kind_for_codec(codec_name);
strview value;
if (json_value(object, svlit("\"language\""), &value))
json_string(value, stream->language, sizeof(stream->language));
if (json_value(object, svlit("\"title\""), &value))
json_string(value, stream->title, sizeof(stream->title));
if (json_truthy(object, "\"default\"")) stream->dispositions |= DISPOSITION_DEFAULT;
if (json_truthy(object, "\"forced\"")) stream->dispositions |= DISPOSITION_FORCED;
if (json_truthy(object, "\"hearing_impaired\"")) stream->dispositions |= DISPOSITION_HEARING_IMPAIRED;
if (json_truthy(object, "\"visual_impaired\"")) stream->dispositions |= DISPOSITION_VISUAL_IMPAIRED;
if (json_truthy(object, "\"comment\"")) stream->dispositions |= DISPOSITION_COMMENT;
if (json_truthy(object, "\"original\"")) stream->dispositions |= DISPOSITION_ORIGINAL;
if (json_truthy(object, "\"dub\"")) stream->dispositions |= DISPOSITION_DUB;
if (json_truthy(object, "\"lyrics\"")) stream->dispositions |= DISPOSITION_LYRICS;
if (json_truthy(object, "\"karaoke\"")) stream->dispositions |= DISPOSITION_KARAOKE;
if (json_truthy(object, "\"clean_effects\"")) stream->dispositions |= DISPOSITION_CLEAN_EFFECTS;
if (json_truthy(object, "\"attached_pic\"")) stream->dispositions |= DISPOSITION_ATTACHED_PIC;
if (json_truthy(object, "\"timed_thumbnails\"")) stream->dispositions |= DISPOSITION_TIMED_THUMBNAILS;
if (json_truthy(object, "\"non_diegetic\"")) stream->dispositions |= DISPOSITION_NON_DIEGETIC;
if (json_truthy(object, "\"captions\"")) stream->dispositions |= DISPOSITION_CAPTIONS;
if (json_truthy(object, "\"descriptions\"")) stream->dispositions |= DISPOSITION_DESCRIPTIONS;
if (json_truthy(object, "\"metadata\"")) stream->dispositions |= DISPOSITION_METADATA;
if (json_truthy(object, "\"dependent\"")) stream->dispositions |= DISPOSITION_DEPENDENT;
if (json_truthy(object, "\"still_image\"")) stream->dispositions |= DISPOSITION_STILL_IMAGE;
if (json_truthy(object, "\"multilayer\"")) stream->dispositions |= DISPOSITION_MULTILAYER;
}
if (video) {
result->has_video = true;
if (known && (!result->video_duration_known || duration > result->video_duration)) {
result->video_duration_known = true;
result->video_duration = duration;
}
} else if (audio) {
result->has_audio = true;
if (known && (!result->audio_duration_known || duration > result->audio_duration)) {
result->audio_duration_known = true;
result->audio_duration = duration;
}
} else {
result->has_subtitles = true;
if (known && (!result->subtitle_duration_known || duration > result->subtitle_duration)) {
result->subtitle_duration_known = true;
result->subtitle_duration = duration;
}
}
}
static void parse_tags(strview object, ProbeResult* result) {
strview tags;
if (!json_value(object, svlit("\"tags\""), &tags)) return;
bool found = false;
size_t start = sv_find_char(tags, '{', &found);
if (!found) return;
sv_chop_left(&tags, start + 1);
size_t end = matching_delimiter(sv_from_data(tags.data - 1, tags.len + 1), '{', '}');
if (end == SIZE_MAX) return;
tags.len = end - 1;
while (tags.len != 0 && result->tag_count < PROBE_TAG_LIMIT) {
sv_trim_left(&tags);
if (tags.len == 0 || tags.data[0] == '}') break;
char key[64] = {0};
if (!json_string(tags, key, sizeof(key))) break;
size_t quote = 1;
bool escaped = false;
while (quote < tags.len) {
if (!escaped && tags.data[quote] == '"') break;
escaped = !escaped && tags.data[quote] == '\\';
if (tags.data[quote] != '\\') escaped = false;
quote++;
}
sv_chop_left(&tags, quote + 1);
bool colon_found = false;
size_t colon = sv_find_char(tags, ':', &colon_found);
if (!colon_found) break;
sv_chop_left(&tags, colon + 1);
sv_trim_left(&tags);
char value[256] = {0};
if (!json_string(tags, value, sizeof(value))) break;
ProbeTag* tag = &result->tags[result->tag_count++];
snprintf(tag->key, sizeof(tag->key), "%s", key);
snprintf(tag->value, sizeof(tag->value), "%s", value);
if (tags.len != 0 && tags.data[0] == '"') {
size_t value_end = 1;
bool value_escape = false;
while (value_end < tags.len) {
if (!value_escape && tags.data[value_end] == '"') break;
value_escape = !value_escape && tags.data[value_end] == '\\';
if (tags.data[value_end] != '\\') value_escape = false;
value_end++;
}
sv_chop_left(&tags, value_end + 1);
}
sv_trim_left(&tags);
if (tags.len != 0 && tags.data[0] == ',') sv_chop_left(&tags, 1);
}
}
static void parse_chapter(strview object, ProbeResult* result) {
if (result->chapter_count >= PROBE_CHAPTER_LIMIT) return;
strview value;
double start = 0, end = 0;
if (!json_value(object, svlit("\"start_time\""), &value) || !json_number(value, &start) ||
!json_value(object, svlit("\"end_time\""), &value) || !json_number(value, &end)) return;
ProbeChapter* chapter = &result->chapters[result->chapter_count++];
chapter->start = start;
chapter->end = end;
if (json_value(object, svlit("\"title\""), &value))
json_string(value, chapter->title, sizeof(chapter->title));
}
static bool parse_probe_json(const char* text, ProbeResult* result) {
strview json = sv_from_cstr(text);
bool streams_found = false;
bool format_found = false;
size_t streams_offset = sv_find(json, svlit("\"streams\""), &streams_found);
size_t format_offset = sv_find(json, svlit("\"format\""), &format_found);
if (!streams_found || !format_found || streams_offset >= format_offset) {
return false;
}
strview streams = sv_from_data(json.data + streams_offset, format_offset - streams_offset);
bool array_found = false;
size_t array_offset = sv_find_char(streams, '[', &array_found);
if (!array_found) {
return false;
}
sv_chop_left(&streams, array_offset + 1);
while (streams.len != 0) {
bool object_found = false;
size_t object_offset = sv_find_char(streams, '{', &object_found);
if (!object_found) {
break;
}
sv_chop_left(&streams, object_offset + 1);
size_t end_offset = matching_delimiter(sv_from_data(streams.data - 1, streams.len + 1), '{', '}');
if (end_offset == SIZE_MAX) {
return false;
}
parse_stream(sv_from_data(streams.data, end_offset - 1), result);
sv_chop_left(&streams, end_offset);
}
strview format = sv_from_data(json.data + format_offset, json.len - format_offset);
bool format_open_found = false;
size_t format_open = sv_find_char(format, '{', &format_open_found);
size_t format_end = format_open_found
? matching_delimiter(sv_from_data(format.data + format_open,
format.len - format_open), '{', '}')
: SIZE_MAX;
if (format_end == SIZE_MAX) {
return false;
}
format = sv_from_data(format.data + format_open + 1, format_end - 1);
parse_tags(format, result);
strview duration_value;
double container_duration = 0;
if (json_value(format, svlit("\"duration\""), &duration_value) &&
json_number(duration_value, &container_duration)) {
if (result->has_video && !result->video_duration_known) {
result->video_duration_known = true;
result->video_duration = container_duration;
}
if (result->has_audio && !result->audio_duration_known) {
result->audio_duration_known = true;
result->audio_duration = container_duration;
}
if (result->has_subtitles && !result->subtitle_duration_known) {
result->subtitle_duration_known = true;
result->subtitle_duration = container_duration;
}
for (size_t i = 0; i < result->stream_count; i++) {
if (!result->streams[i].duration_known) {
result->streams[i].duration_known = true;
result->streams[i].duration = container_duration;
}
}
}
bool chapters_found = false;
size_t chapters_offset = sv_find(json, svlit("\"chapters\""), &chapters_found);
if (chapters_found) {
strview chapters = sv_from_data(json.data + chapters_offset, json.len - chapters_offset);
bool open_found = false;
size_t open = sv_find_char(chapters, '[', &open_found);
if (open_found) {
sv_chop_left(&chapters, open + 1);
while (chapters.len != 0) {
bool object_found = false;
size_t object = sv_find_char(chapters, '{', &object_found);
if (!object_found) break;
sv_chop_left(&chapters, object);
size_t close = matching_delimiter(chapters, '{', '}');
if (close == SIZE_MAX) break;
parse_chapter(sv_from_data(chapters.data + 1, close - 1), result);
sv_chop_left(&chapters, close + 1);
}
}
}
return result->has_video || result->has_audio || result->has_subtitles;
}
bool probe_run_ffprobe(const char* path, ProbeResult* result, void* context, Error* error) {
(void)context;
int pipe_fds[2];
if (pipe(pipe_fds) != 0) {
return probe_error(error, "cannot create pipe for '%s'", path);
}
pid_t child = fork();
if (child < 0) {
close(pipe_fds[0]);
close(pipe_fds[1]);
return probe_error(error, "cannot start ffprobe for '%s'", path);
}
if (child == 0) {
close(pipe_fds[0]);
dup2(pipe_fds[1], STDOUT_FILENO);
dup2(pipe_fds[1], STDERR_FILENO);
close(pipe_fds[1]);
char* const argv[] = {
"ffprobe", "-v", "error", "-show_entries",
"format=duration:format_tags:stream=index,codec_name,codec_type,duration:"
"stream_tags=language,title,filename,mimetype:stream_disposition:"
"chapter=start_time,end_time:chapter_tags=title", "-show_chapters", "-of", "json",
(char*)path, NULL,
};
execvp(argv[0], argv);
_exit(127);
}
close(pipe_fds[1]);
char* output = NULL;
bool read_ok = read_process(pipe_fds[0], &output);
close(pipe_fds[0]);
int status = 0;
while (waitpid(child, &status, 0) < 0 && errno == EINTR) {
}
if (!read_ok || !WIFEXITED(status) || WEXITSTATUS(status) != 0) {
free(output);
return probe_error(error, "ffprobe failed for '%s'", path);
}
*result = (ProbeResult){0};
bool parsed = parse_probe_json(output, result);
free(output);
if (!parsed) {
return probe_error(error, "could not parse ffprobe output for '%s'", path);
}
return true;
}