Nothing
# Config directory ---------------------------------------------------------
# Where a remembered program location lives. `tools::R_user_dir()` is the one
# user config location CRAN policy sanctions (M097); it honors
# `R_USER_CONFIG_DIR`, which is how the suite keeps its writes out of the
# user's real configuration.
tm_config_dir <- function() {
tools::R_user_dir("tidymedia", "config")
}
# Where a location set before M097 was written. Delegated to the library that
# wrote it rather than reconstructed by hand: the legacy layout differs per
# platform, and a single-platform suite cannot catch a second copy of those
# rules drifting. The only place the package computes it (M097 AC5).
tm_legacy_config_dir <- function() {
rappdirs::user_config_dir("tidymedia", "R")
}
tm_config_file <- function(program, dir = tm_config_dir()) {
file.path(dir, glue("{program}_location.txt"))
}
# Data directory -----------------------------------------------------------
# Where `install_on_win()` puts a downloaded FFmpeg build when the caller names
# no directory. `tools::R_user_dir()` is the one user data location CRAN policy
# sanctions (M098); it honors `R_USER_DATA_DIR`, which is how the suite keeps
# its writes out of the user's real data directory. The `ffmpeg` subdirectory
# is the extraction root: `archive_extract(strip_components = 1)` unpacks the
# build's contents into it, so the binaries land under `bin/` beneath it.
tm_install_dir <- function() {
file.path(tools::R_user_dir("tidymedia", "data"), "ffmpeg")
}
# find_program() ----------------------------------------------------------
# Whether a location names a program this machine can run, and where it cannot,
# why not. One state per element: "runnable", "not_executable" or "absent".
#
# `Sys.which()` is not the instrument for this question, which is what CRAN's
# two Fedora check flavors measured at 0.2.1. It shells out to `/usr/bin/which`,
# so its answer for an ABSOLUTE PATH is whatever that machine's `which` does
# with a path, and on those two images it answered `""` for stub executables the
# tests had just created. 82 tests failed through this seam, none of them about
# anything the package had got wrong. Whether a runnable program sits at a path
# is a question the filesystem answers the same way on every machine, so that is
# what is asked here (M138).
#
# A location holding no directory separator is a different question. It is a
# bare NAME, and finding it means searching `PATH`, which is the one question
# `Sys.which()` is the instrument for. `set_program()` accepts that form, so the
# call stays for it (the `onpath` case in test-program-management.R).
#
# What "can run" means depends on the platform. `os` is the host's own
# `.Platform$OS.type`, and a test passes "windows" to reach that branch on
# another host. It is deliberately not `tm_os()`: the install tests hold that
# seam at "windows" while planting POSIX permission states, and the refusals
# they pin are read by the host's own rule (D098).
# * POSIX: a file whose execute bit is set, `file.access(path, 1L) == 0`. A file
# that is there without the bit is "not_executable".
# * Windows: the rule R's own `Sys.which()` and `system()` apply there, which is
# `expandcmd()` in R's `src/gnuwin32/run.c` (read 2026-10-10). A name whose
# last component holds a `.` is taken as it is, and any file there counts. A
# name without one is tried with `.exe`, `.com`, `.cmd` and `.bat` appended.
# Anything else is "absent" (D099). Two departures: a directory never counts
# here, which D084 requires and `expandcmd()`'s `SearchPath()` may not
# promise; and `file.access()` is not asked. Its Windows help section (read
# in R's source, 2026-10-10) counts a directory as executable and does not
# try the four extensions on a bare name, so it answers a different
# question. The rule accepts a file `system()` cannot start, such as
# `notes.txt`, exactly as `Sys.which()` did (D100).
# `tm_usable_binary()` keeps its own non-empty test on top, because neither rule
# refuses a truncated `ffmpeg.exe` (D083).
#
# `~` is expanded by `file.info()` and `file.access()` themselves, so this
# predicate needs no `path.expand()`. The two writers of a location expand it
# instead, because `system2()` does not (D098).
#
# Vectorized, so the whole produced set of an install is asked in one call
# (M105). A directory is folded into "absent" rather than given a state of its
# own: find_program()'s two warnings are the only reader that branches on the
# answer, and a directory at a remembered location was already reported as gone.
tm_program_state <- function(location, os = .Platform$OS.type) {
state <- rep("absent", length(location))
if (!length(location)) return(state)
# NA in, "absent" out, so the three states in the header are the only three
# this answers. `Sys.which()` returns NA for an NA name, which would have
# made a fourth (M138 review).
location[is.na(location)] <- ""
named_path <- grepl("[/\\\\]", location)
if (any(named_path)) {
path <- location[named_path]
info <- file.info(path, extra_cols = FALSE)
is_file <- !is.na(info$isdir) & !info$isdir
state[named_path] <- if (identical(os, "windows")) {
has_ext <- grepl("[.][^/\\\\]*$", path)
found <- is_file & has_ext
bare <- which(!has_ext)
for (ext in c(".exe", ".com", ".cmd", ".bat")) {
if (!length(bare)) break
with_ext <- file.info(paste0(path[bare], ext), extra_cols = FALSE)
hit <- !is.na(with_ext$isdir) & !with_ext$isdir
found[bare[hit]] <- TRUE
bare <- bare[!hit]
}
ifelse(found, "runnable", "absent")
} else {
ifelse(
is_file & file.access(path, 1L) == 0L,
"runnable", ifelse(is_file, "not_executable", "absent")
)
}
}
if (any(!named_path)) {
state[!named_path] <- ifelse(
unname(Sys.which(location[!named_path])) != "", "runnable", "absent"
)
}
state
}
# The yes-or-no form, for the two callers that do not branch on the reason.
tm_runnable_program <- function(location) {
tm_program_state(location) == "runnable"
}
# The internal lookup behind find_ffmpeg() and its three siblings. It is not
# exported, so its behavior is documented on their help page (?find_ffmpeg),
# whose roxygen block sits on find_ffmpeg() below.
find_program <- function(program = c("ffmpeg", "ffprobe", "ffplay", "mediainfo")) {
# Validate arguments
program <- rlang::arg_match(program)
# First, look for program in path
location <- Sys.which(program)
if (location == "") {
# If program not found, look for a user config file: the R_user_dir()
# location first, then the pre-M097 one, so a location set before
# the move is still found. The read falls back; nothing is migrated.
config <- tm_config_file(program)
if (!file.exists(config)) {
config <- tm_config_file(program, tm_legacy_config_dir())
}
# If a user config file exists, read it in
if (file.exists(config)) {
location <- readLines(config)
# What was read back must be ONE line before anything tests it. A file
# holding nothing gives `if (logical(0))` and one holding two or more
# gives a length-2 condition, so both aborted the lookup -- and every
# verb above it -- with an R error naming no program and no file
# (measured 2026-09-06: "argument is of length zero" and "the condition
# has length > 1"). set_program() cannot write either shape; a truncated
# write or a hand-edit can. The same `length(x) != 1L` shape
# count_audio_streams() documents at R/ffprobe.R:220-221 and applies at
# :223, and for the same reason: is.na() on character(0) answers logical(0), which `if`
# rejects in its turn.
if (length(location) != 1L || is.na(location) || !nzchar(location)) {
cli::cli_warn(
c(
"{program} is configured in {.file {config}}, but that file does \\
not hold one location.",
"i" = "Use {.code unset_program(\"{program}\")} to forget it, or \\
{.fn set_{program}} to point tidymedia at the binary again."
),
class = "tidymedia_location_unreadable",
tm_program = program,
tm_file = config
)
return(NULL)
}
# A hand-edited `~` line is expanded here, where the location enters the
# package, because `system2()` and `ffprobe()` pass it to the shell
# quoted, and a quoted `~` is not expanded (measured 2026-10-10:
# `sh: ~/bin/ffprobe: No such file or directory`; D098). The two
# warnings below still name the line as the file holds it, so a caller
# can find it there.
expanded <- path.expand(location)
# Is the location still a program this machine can run? Two ways it is
# not, and they take different advice: a file that is no longer there is
# repaired by pointing somewhere else, and one that is there but cannot
# be run is repaired by the permission bit. Before M138 both answered
# `Sys.which() == ""` and both were reported as gone, which sent a caller
# looking for a file that was in front of them.
state <- tm_program_state(expanded)
if (identical(state, "runnable")) {
location <- expanded
} else if (identical(state, "not_executable")) {
cli::cli_warn(
c(
"{program} is configured at {.file {location}}, but that file \\
cannot be run.",
"i" = "Give it permission to execute, or use \\
{.fn set_{program}} to point tidymedia at another copy.",
"i" = "Or use {.code unset_program(\"{program}\")} to forget the \\
remembered location.",
tm_install_bullet(program)
),
class = "tidymedia_location_not_executable",
tm_program = program,
tm_location = location
)
location <- NULL
} else {
cli::cli_warn(
c(
"{program} was configured at {.file {location}} but that file no \\
longer seems to exist.",
"i" = "Use {.fn set_{program}} to point tidymedia at it again.",
"i" = "Or use {.code unset_program(\"{program}\")} to forget the \\
remembered location.",
tm_install_bullet(program)
),
class = "tidymedia_location_gone",
tm_program = program,
tm_location = location
)
location <- NULL
}
} else {
# If config file not found, return NULL value and warning
location <- NULL
cli::cli_warn(c(
"Failed to find {program}.",
"i" = "Check that it is installed, then use {.fn set_{program}} to \\
point tidymedia at it.",
tm_install_bullet(program)
))
}
}
location
}
# install_on_win() --------------------------------------------------------
# Its helpers sit further down, under "install_on_win() helpers". The
# roxygen blocks run basic use first: install, status, find, set, unset.
#' Install FFmpeg on Windows
#'
#' @description
#' `install_on_win()` downloads a Windows build of FFmpeg and unpacks it. Then
#' it saves the locations of `ffmpeg`, `ffprobe` and `ffplay`, as
#' [set_program()] does. After that, the package can find these programs.
#'
#' By default, the call downloads the latest "essentials" build from gyan.dev.
#' It unpacks the build into the `ffmpeg` folder under
#' `tools::R_user_dir("tidymedia", "data")`.
#'
#' By default, the call asks you to confirm before it does anything. The
#' question names each file it will download and the folder it will unpack
#' into. It also names
#' the saved program locations that the install can replace. If you say no, the
#' call returns `FALSE` and changes nothing.
#'
#' This function works on Windows only. On any other system, it gives an error
#' before it asks, writes or downloads anything. The error names the system it
#' found. On macOS, you can install FFmpeg with `brew install ffmpeg`. On Linux,
#' you can use `sudo apt-get install ffmpeg`. On any system, [set_program()]
#' tells the package where an installed FFmpeg is.
#'
#' @details
#' Before the call unpacks the archive, it checks the archive against a SHA-256
#' checksum. A checksum is a fingerprint of the file's contents. For the
#' default source, the call downloads the checksum that gyan.dev publishes next
#' to each build. That file has the archive's address with `.sha256` added. For
#' any other source, give the checksum in `archive_checksum`.
#'
#' The published checksum comes from the same site as the archive, over the
#' same connection. So the check finds a damaged or incomplete download. It
#' does not find a source that someone has tampered with.
#'
#' After the unpack, the call checks each program before it saves any location.
#' The path must be one that R finds as a program. It must be a file, not a
#' folder, and the file must not be empty. The call does not run the program.
#' So a build for the wrong type of processor can pass this check.
#'
#' The package needs `ffmpeg` and `ffprobe`. If either one fails the check, the
#' call saves no location and gives an error. The error names each failed
#' program and its full path. If `ffplay` is missing or fails the check, the
#' install finishes. A message says that the call did not save `ffplay`.
#'
#' @section What a failed install leaves behind:
#' When the call gives an error, it tries to leave the install folder as it
#' found it. It removes the files that a failed unpack wrote. It removes a
#' folder that the call created. It does not touch the files that were already
#' in the folder, with one exception.
#'
#' The exception is a file of yours that the failed unpack wrote over. The call
#' removes that file too, because the file no longer holds what you put there.
#'
#' On Windows, the removal can fail. After a failed unpack, the unpack library
#' can still hold a file open. Windows does not delete a file that is open.
#'
#' The error names by full path the entries of the first case below that
#' applies:
#'
#' * each unpacked file that the call could not remove
#' * each folder that the call created and could not remove
#' * each file of yours that the call removed
#'
#' Two errors come after a successful unpack: `tidymedia_program_not_extracted`
#' and `tidymedia_program_unusable`. These errors leave the unpacked files in
#' the folder, and they say so.
#'
#' The call learns which files the unpack made from the archive's own list and
#' from the folder. A program that the list names but that is not in the folder
#' counts as not unpacked. For example, antivirus software can remove a program
#' right after the unpack. The error then says that the unpack reported writing
#' that file.
#'
#' If none of the unpacked files are in the folder,
#' `tidymedia_program_not_extracted` follows the usual rule. The call removes a
#' folder that it created, and the error says so.
#'
#' @section Errors:
#' The call gives an error of its own class in these cases:
#'
#' * `tidymedia_wrong_platform`: the session is not running on Windows.
#' * `tidymedia_confirmation_unavailable`: the call must ask you to confirm,
#' but no one can answer in this session.
#' * `tidymedia_download_unavailable`: the archive did not download, or nothing
#' readable arrived.
#' * `tidymedia_checksum_unavailable`: the call could not download or read the
#' published checksum.
#' * `tidymedia_checksum_mismatch`: the downloaded archive does not match its
#' checksum.
#' * `tidymedia_archive_unreadable`: the call could not unpack the archive.
#' * `tidymedia_program_not_extracted`: `ffmpeg` or `ffprobe` is not at the path
#' where the install would put it.
#' * `tidymedia_program_unusable`: the archive made `ffmpeg` or `ffprobe`, but
#' the file cannot be used.
#'
#' @param download_url A string with the address of the FFmpeg archive. If
#' `NULL`, the call uses the latest "essentials" build from gyan.dev, a `.7z`
#' archive.
#' @param install_dir A string with the folder to install FFmpeg into. If
#' `NULL`, the call uses the `ffmpeg` folder under
#' `tools::R_user_dir("tidymedia", "data")`. CRAN allows packages to keep user
#' data in that place.
#' @param confirm `TRUE` or `FALSE`. Whether to ask before the call downloads
#' or installs anything. Defaults to `TRUE`. In a session where no one can
#' answer, `TRUE` gives an error that names the same items as the question.
#' Pass `confirm = FALSE` to install without the question.
#' @param archive_checksum A string with the expected SHA-256 checksum of the
#' archive, as 64 hexadecimal characters in upper or lower case. Defaults to
#' `NULL`. If you give a checksum, the call uses it for any source and
#' downloads no checksum. If it is `NULL` and `download_url` is not the
#' default source, the call checks nothing and says so.
#' @return `TRUE` when the install finished. `FALSE` when you said no to the
#' question, or when the call could not create the install folder. Other
#' failures give an error. The section "Errors" lists the error classes the
#' call gives. A wrong argument gives an error before any of these.
#' @seealso [set_program()] to save the location of a program you already have,
#' and [find_ffmpeg()] to check where the package finds a program.
#' @family program management functions
#' @examples
#' \dontrun{
#' # Download and install a static FFmpeg build (Windows)
#' install_on_win()
#' }
#' @export
install_on_win <- function(download_url = NULL,
install_dir = NULL,
confirm = TRUE,
archive_checksum = NULL) {
rlang::check_bool(confirm)
# The two path-shaped arguments are checked here for the same reason the
# other two are: without it a non-string reaches `dir.create()` or
# `download.file()` and leaves through a bare `simpleError`, which is an
# exit carrying no class of its own and one the AC6 census cannot see --
# it is neither a `return()` nor a `cli_abort()` node (M102).
rlang::check_string(download_url, allow_null = TRUE)
rlang::check_string(install_dir, allow_null = TRUE)
check_sha256(archive_checksum, allow_null = TRUE)
# The one platform this function installs for. The gate is an allow-list --
# anything that is not `windows` is refused -- so a host the routing table
# has never heard of is refused rather than sent to download a Windows build
# it cannot run. It sits BELOW the four argument checks, because an argument
# mistake is worth reporting on any machine and a cheap value refusal comes
# first (D036, D043), and ABOVE every cost: nothing has been said to the
# caller, asked of them, written, or fetched by the time it fires.
platform <- tm_os()
if (!identical(platform, "windows")) {
# Single-bracket, so a platform the table does not name gives NA rather
# than a subscript error.
route <- unname(tm_install_routes[platform])
known <- unname(tm_os_names[platform])
# The two bullets below are written as a pair per platform rather than as
# one line plus an optional one: with no route to name, "Then point
# tidymedia at it" would promise a step that is not there and refer to
# nothing, which is what the platforms this allow-list exists to serve --
# FreeBSD, Solaris, the coarse `unix` fallback -- would read.
advice <- if (is.na(route)) {
c("i" = "Point tidymedia at an FFmpeg build you already have with
{.fun set_program}.")
} else {
c(
"i" = "Install FFmpeg with {.code {route}}.",
"i" = "Then point tidymedia at it with {.fun set_program}, or at a
build you already have."
)
}
cli::cli_abort(
c(
"{.fun install_on_win} installs FFmpeg on Windows only.",
"x" = if (is.na(known)) {
"This session is running on {platform}."
} else {
"This session is running on {platform} ({known})."
},
advice
),
class = "tidymedia_wrong_platform",
tm_platform = platform
)
}
if (is.null(download_url)) {
download_url <- tm_default_download_url
}
if (is.null(install_dir)) {
install_dir <- tm_install_dir()
}
# Which digest, if any, this archive gets checked against.
#
# A digest the caller supplied wins on every source, the package's own
# default included: someone holding a digest already is never sent to fetch
# a second copy of one. Otherwise only the default source has a sidecar to
# fetch, and the test is against the RESOLVED url -- so a caller who types
# that address out in full is verified exactly like one who named nothing.
sidecar_url <- NULL
if (is.null(archive_checksum) &&
identical(download_url, tm_default_download_url)) {
sidecar_url <- tm_sidecar_url(download_url)
}
# A caller-named source with no digest to check against is installed anyway
# -- refusing would leave such a caller no route at all -- but never
# silently, and never after the fact: it is said above the prompt, so what
# a caller approves includes knowing that nothing will check the download.
if (is.null(archive_checksum) && is.null(sidecar_url)) {
cli::cli_inform(c(
"!" = "The archive at {.url {download_url}} will not be verified.",
"i" = "Pass {.arg archive_checksum} to check it against a digest you
have."
))
}
# The consent sits above the first write, so a declined call creates no
# directory, downloads nothing, and overwrites no remembered location
# (D080). It asks about the RESOLVED values: a caller who named neither
# argument is told what the defaults came out to. The sidecar is named here
# for the same reason the archive is -- the prompt names every fetch the
# call makes, and only the fetches it makes.
if (confirm) {
details <- tm_install_details(
download_url, install_dir, tm_install_registers, sidecar_url
)
approved <- tm_confirm(
tm_install_prompt(
download_url, install_dir, tm_install_registers, sidecar_url
),
"i" = "This install would have done the following:",
rlang::set_names(tm_cli_escape(details), rep("*", length(details))),
"i" = "Pass {.code confirm = FALSE} to install without being asked."
)
if (!approved) return(FALSE)
}
# What this call is about to make, and the promise to take it back again.
# The handler is registered ABOVE the create because the create itself can
# half-succeed -- `recursive = TRUE` makes the parents and then fails on the
# leaf -- and every refusal below here has to leave the directory as the
# call found it (M103 AC3). It is disarmed the moment the extraction
# produces FILES, which is one step earlier than "once a program has been
# registered": everything below an extraction that wrote something is
# outside the rule, because those files are in that directory and
# `tidymedia_program_not_extracted` tells the caller so. Disarming at
# registration instead let that abort delete the directory its own message
# pointed at (M103 review pass 1); disarming on a merely SUCCESSFUL
# extraction let it keep an empty directory this call created, under the
# same message, wherever the archive produced nothing -- single-segment
# entries, every one stripped by `strip_components = 1` (M103 review pass
# 2). The carve-out is written over the files a successful extraction
# leaves, so where it leaves none it does not reach. It is also a no-op
# wherever the directory holds anything: a directory that already existed is
# never in `created_dirs` at all.
created_dirs <- tm_missing_ancestors(install_dir)
unpacked_here <- FALSE
on.exit(if (!unpacked_here) tm_remove_created_dirs(created_dirs), add = TRUE)
if (!dir.exists(install_dir)) {
status <- dir.create(install_dir, recursive = TRUE)
if (status == FALSE) return(FALSE)
}
# Both temporary files go on the exit handler rather than being unlinked at
# the end: every abort below happens between here and there, and a refusal
# that leaves a several-hundred-megabyte download behind is a bug the
# succeeding path would never show (M102 AC3).
if (!is.null(sidecar_url)) {
sidecar_file <- tempfile()
on.exit(unlink(sidecar_file), add = TRUE)
# The digest is fetched BEFORE the archive: a source that cannot produce
# one refuses in a second rather than after a long download.
fetched <- tm_fetch(sidecar_url, sidecar_file)
if (!isTRUE(fetched)) {
cli::cli_abort(
"Can't fetch the published digest at {.url {sidecar_url}}.",
class = "tidymedia_checksum_unavailable",
parent = if (rlang::is_condition(fetched)) fetched
)
}
# The read is guarded for the same reason the fetch is: `download.file()`
# reporting status 0 is not a promise that a readable file arrived, and a
# bare "cannot open connection" here would be the one exit on this path
# carrying no class of its own.
body <- tryCatch(
readLines(sidecar_file, warn = FALSE),
error = function(cnd) NULL,
warning = function(cnd) NULL
)
archive_checksum <- tm_parse_sidecar(body)
if (is.null(archive_checksum)) {
cli::cli_abort(
c(
"Can't read a SHA-256 digest from {.url {sidecar_url}}.",
"i" = "Expected 64 hexadecimal characters, on their own or in a
{.code sha256sum} or {.code SHA256(file)=} line."
),
class = "tidymedia_checksum_unavailable"
)
}
}
tf <- tempfile()
on.exit(unlink(tf), add = TRUE)
fetched <- tm_fetch(download_url, tf)
if (!isTRUE(fetched)) {
cli::cli_abort(
"Can't download the archive at {.url {download_url}}.",
class = "tidymedia_download_unavailable",
parent = if (rlang::is_condition(fetched)) fetched
)
}
if (!is.null(archive_checksum)) {
found <- tm_archive_digest(tf)
# A download that cannot be read is a download that did not deliver, and
# it refuses as one: reporting a mismatch against a digest that could not
# be computed would name a cause that is not the cause.
if (is.null(found)) {
cli::cli_abort(
c(
"Can't download the archive at {.url {download_url}}.",
"i" = "The download reported success but nothing readable arrived,
so its digest could not be computed."
),
class = "tidymedia_download_unavailable"
)
}
if (!identical(found, tolower(archive_checksum))) {
cli::cli_abort(
c(
"The downloaded archive does not match its SHA-256 digest.",
"i" = "Expected {.val {tolower(archive_checksum)}}.",
"x" = "Downloaded {.val {found}}."
),
class = "tidymedia_checksum_mismatch"
)
}
}
produced <- tm_unpack(tf, install_dir)
if (is.null(produced$files)) {
# The directories this call made come back before the message is built,
# not after it: the caller reads the message once, and a line naming a
# directory the exit handler is about to delete would be false by the time
# they went to look. Where the unpack left something behind the directory
# holds it, so the removal stops of its own accord -- the test below is
# the belt to that braces (M103 AC7).
kept_created <- created_dirs
if (!length(produced$leftovers)) {
kept_created <- tm_remove_created_dirs(created_dirs)
}
# `cli_vec()` with the truncation off, because AC7 promises every entry by
# name: cli abbreviates a vector in a message at 20 by default -- measured
# 2026-09-02 on 25 paths, entries 19 through 23 came back as an ellipsis --
# and a caller told about 20 of 25 files still on their disk would have to
# go hunting for the rest. A long list is the lesser cost, and it is a
# list nothing but a failed install can produce (M103 AC7).
left <- cli::cli_vec(
file.path(install_dir, produced$leftovers), list("vec-trunc" = Inf)
)
# The caller's own entries the cleanup removed, and the directories this
# call created and could not take back: the two states the plain "holds
# what it held" sentence used to describe wrongly (M103 review pass 3).
yours <- cli::cli_vec(
file.path(install_dir, produced$removed_yours), list("vec-trunc" = Inf)
)
kept <- cli::cli_vec(kept_created, list("vec-trunc" = Inf))
cli::cli_abort(
c(
"Can't unpack the downloaded archive.",
"i" = "Archive: {.file {tf}}.",
if (length(left)) {
c(
"i" = "Install directory: {.file {install_dir}}.",
"!" = "{cli::qty(length(left))}{?This entry/These entries} could not
be removed and {cli::qty(length(left))}{?is/are} still
there: {.file {left}}."
)
} else if (!dir.exists(install_dir)) {
c("i" = "This call created the install directory and has removed it
again; nothing was left behind.")
} else if (length(kept)) {
c(
"i" = "Install directory: {.file {install_dir}}.",
"!" = "Nothing the extraction wrote is still there, but this call
created {cli::qty(length(kept))}{?this directory/these
directories} and could not remove {cli::qty(length(kept))}
{?it/them} again: {.file {kept}}."
)
} else if (length(yours)) {
c(
"i" = "Install directory: {.file {install_dir}}.",
"!" = "Nothing the extraction wrote is still there, but the failed
extraction had written over {cli::qty(length(yours))}
{?this file/these files} of yours, which {cli::qty(length(yours))}
{?was/were} removed with it: {.file {yours}}."
)
} else {
c(
"i" = "Install directory: {.file {install_dir}}.",
"i" = "Nothing was left behind; the directory holds what it held
when this call started."
)
}
),
class = "tidymedia_archive_unreadable"
)
}
# From here the extraction has succeeded and its files are in the install
# directory, so nothing below may take that directory back (M103 AC4). What
# counts as "its files" is the reported list intersected with the disk, and
# every question below asks that one set: a list entry holding nothing is a
# file no caller can be sent to, and a directory holding none of them is one
# this call may still take back (M105).
on_disk <- tm_files_on_disk(produced$files, install_dir)
unpacked_here <- length(on_disk) > 0L
# Register what the extraction actually produced, and nothing else: a
# remembered location pointing at a file the archive never contained is a
# worse state than no remembered location at all. Which paths are candidates
# is still read off this extraction's own file list, never off a listing of
# the install directory: `install_dir` defaults to one stable path across
# installs and the extraction does not clear it, so a directory listing
# would count a previous run's binaries as this build's. Each of those paths
# is then asked whether it is there. That narrows the candidates to this
# build's own list; it does not prove the file AT such a path was written by
# this extraction rather than left by an earlier one, which `file.exists()`
# cannot tell and nothing below asks it to (M105 review F6). The required
# programs are checked before the first write, so
# a build missing one leaves every existing remembered location as it was.
unpacked <- tm_extracted_programs(on_disk, tm_install_registers)
absent_required <- setdiff(tm_install_required, unpacked)
if (length(absent_required)) {
# The required programs the extraction REPORTED and did not leave behind,
# told apart from the ones it never claimed: they are the same refusal --
# neither is a program this install can register -- but only one of them
# has a cause the caller can act on (M105).
vanished <- intersect(
absent_required, tm_extracted_programs(produced$files, tm_install_required)
)
# The directories this call made come back before the message is built,
# for the same reason the unreadable-archive refusal does it there: the
# caller reads the message once, and it has to describe the state they
# will find. `tm_remove_created_dirs()` stops at the first directory that
# is not empty, so an extraction that wrote files keeps its directory of
# its own accord and the guard below is what decides the wording.
kept_created <- created_dirs
if (!length(on_disk)) {
kept_created <- tm_remove_created_dirs(created_dirs)
}
kept <- cli::cli_vec(kept_created, list("vec-trunc" = Inf))
# Where every missing program is one the extraction REPORTED, "did not
# produce" would contradict the line below it, which says the extraction
# reported writing exactly those paths. The archive listed them; what it
# did not do is leave them behind (M105 review F2).
headline <- if (length(vanished) == length(absent_required)) {
"The archive did not leave behind {.and {.file {absent_required}}}."
} else {
"The archive did not produce {.and {.file {absent_required}}}."
}
# An extraction that reported no files at all and one that reported files
# and left none of them are the same disposition and not the same event,
# and only the second has a report to be absent from. Saying "none of the
# files the extraction reported are there" of an empty report implies a
# report that never happened, which is the wording M103 had exact before
# this milestone re-pointed the guard (M105 review F3).
nothing_there <- if (length(produced$files)) {
"None of the files the extraction reported are there."
} else {
"The archive produced no files at all."
}
cli::cli_abort(
c(
headline,
"i" = "Looked for {.and {.file {tm_install_binary(install_dir,
absent_required)}}}.",
if (length(vanished)) {
c("!" = "The extraction reported writing {.and {.file
{tm_install_binary(install_dir, vanished)}}}, but
{cli::qty(length(vanished))}{?it is/they are} not there.
Antivirus quarantine after extraction is the usual cause.")
},
if (length(on_disk)) {
c("i" = "Nothing was registered; the files the extraction did
produce are in {.file {install_dir}}.")
} else if (!dir.exists(install_dir)) {
c("i" = "{nothing_there} Nothing was registered, and this call has
removed the install directory it created.")
} else if (length(kept)) {
c("i" = "{nothing_there} Nothing was registered, and this call
created {cli::qty(length(kept))}{?this directory/these
directories} and could not remove {cli::qty(length(kept))}
{?it/them} again: {.file {kept}}.")
} else {
c("i" = "{nothing_there} Nothing was registered, and the install
directory holds what it held when this call started.")
}
),
class = "tidymedia_program_not_extracted"
)
}
# Every path the extraction produced is asked whether it can be used BEFORE
# the first set_program() call, so a build carrying a program the caller
# cannot run leaves every existing remembered location as it was rather than
# registering the ones that happened to come first. The whole set is
# partitioned once here; the two branches below dispose of the required and
# the optional halves (M104 AC1).
unusable <- unpacked[!tm_usable_binary(tm_install_binary(install_dir, unpacked))]
unusable_required <- intersect(tm_install_required, unusable)
if (length(unusable_required)) {
# Nothing here touches the install directory. This sits below a successful
# extraction, so the archive's files are in that directory and D082's rule
# stops at exactly that boundary: a caller told the build cannot be used
# is left the build to look at.
cli::cli_abort(
c(
"The archive produced {.and {.file {unusable_required}}}, but
{cli::qty(length(unusable_required))}{?it/they} cannot be used.",
"i" = "Checked {.and {.file {tm_install_binary(install_dir,
unusable_required)}}}.",
"i" = "A produced program is registered only where its path resolves
to a file that is not empty; the file itself is never run.",
"i" = "Nothing was registered; whatever the archive unpacked is still
in {.file {install_dir}}."
),
class = "tidymedia_program_unusable"
)
}
absent_optional <- setdiff(tm_install_registers, unpacked)
unusable_optional <- setdiff(unusable, tm_install_required)
# One message where both kinds occur in a call, and a distinct sentence for
# each: the archive-did-not-produce wording is false of a program the
# archive DID produce, and a caller sent looking for a file that is on their
# disk is a worse state than no message at all (M104 AC3).
if (length(absent_optional) || length(unusable_optional)) {
cli::cli_inform(c(
if (length(absent_optional)) {
c("i" = "The archive did not produce {.and {.file {absent_optional}}};
no location was remembered for
{cli::qty(length(absent_optional))}{?it/them}.")
},
if (length(unusable_optional)) {
c("i" = "The archive produced {.and {.file {unusable_optional}}}, but
what it produced could not be used, so no location was
remembered for {cli::qty(length(unusable_optional))}
{?it/them}: {.file {tm_install_binary(install_dir,
unusable_optional)}}.")
}
))
}
# `confirm = FALSE`: the install's own prompt above already named every one
# of these overwrites by full path, so asking again here would ask a second
# time for consent already given -- once per program, at that.
for (program in setdiff(unpacked, unusable)) {
set_program(program, tm_install_binary(install_dir, program), confirm = FALSE)
}
TRUE
}
# program_status() --------------------------------------------------------
#' Report which dependency programs tidymedia can find
#'
#' @description
#' `program_status()` looks for the four programs that the package uses:
#' `ffmpeg`, `ffprobe`, `ffplay` and `mediainfo`. It returns a table with one
#' row for each program. The row shows where the program is and which version
#' it reports. The call does not install, write or change anything.
#'
#' @details
#' The call looks in the same places as [find_ffmpeg()]. First it looks on the
#' `PATH`, then at a location saved by [set_program()]. For locations saved by
#' versions before 0.2.0, see the section "Locations saved by earlier versions"
#' in [find_ffmpeg()].
#'
#' A program that is not installed and has no saved location gets `NA` in both
#' columns. This case gives no warning, so four missing programs give one table
#' and not four warnings.
#'
#' A saved location that cannot be used still gives a warning. Without it, the
#' `NA` would look like a program you never had. Each warning names the saved
#' location or the file that holds it, so you can fix it. There are three cases:
#'
#' * `tidymedia_location_gone`: no program is at the saved location now.
#' * `tidymedia_location_not_executable`: a file is there, and it cannot be
#' run. Give it permission to execute, or save another copy. This case does
#' not arise on Windows, which has no execute bit.
#' * `tidymedia_location_unreadable`: the file that stores the location does
#' not hold exactly one location.
#'
#' In all three cases, the row has `NA` in both columns. [unset_program()]
#' forgets the location, and [set_program()] replaces it.
#'
#' The version is what the program reports about itself. For `ffmpeg`,
#' `ffprobe` and `ffplay`, it is the FFmpeg build number. For `mediainfo`, it is
#' the MediaInfo library version.
#'
#' Sometimes the call finds a program but cannot get its version. Then the row
#' has a location and an `NA` version. This happens when the program call fails.
#' It also happens when the time limit in `options(tidymedia.timeout = )` stops
#' it.
#'
#' @return A tibble with one row for each program and three columns:
#'
#' * `program`, the name of the program.
#' * `location`, the path to the program, or `NA`.
#' * `version`, the version that the program reported, or `NA`.
#' @seealso [find_ffmpeg()] and the other `find_*()` functions to look up one
#' program. [set_program()] to save the location of a program that is not on
#' the `PATH`. [unset_program()] to forget a saved location.
#' @family program management functions
#' @examplesIf nzchar(Sys.which("ffmpeg"))
#' # One row per program; NA where the program was not found
#' program_status()
#' @export
program_status <- function() {
programs <- tm_programs()
# Of the four states find_program() warns in, exactly one is already carried
# by the table: a program that is simply not there, whose NA in both columns
# IS "not found", and four such warnings would bury the answer the call
# exists to return. The other three name a file on this machine the caller
# can repair -- a remembered location whose binary is gone, one whose binary
# cannot be run (M138), and a config file that does not hold one location --
# and there NA is indistinguishable from
# never-configured, so a silent report sends the caller off to install a
# program they have already got (D088). Selected by class rather than by
# message, and by the classes to be RAISED rather than the one to be
# muffled, so a warning added later is silent until it is decided about.
locations <- lapply(programs, function(program) {
withCallingHandlers(
find_program(program),
warning = function(w) {
if (!inherits(w, tm_audible_location_classes())) {
invokeRestart("muffleWarning")
}
}
)
})
# The version probe's own timeout warning is NOT suppressed: it says the
# limit ended the probe, which is a different fact from the program being
# absent and is the one D048 made audible.
versions <- tool_versions(programs, locations, call = rlang::current_env())
tibble::tibble(
program = programs,
location = vapply(locations, tm_na_string, character(1)),
# unname(): tool_versions() answers a list named by program, and a named
# column prints its names back at the reader in every row label.
version = unname(vapply(versions, tm_na_string, character(1)))
)
}
# The find_program() warnings a reader of the answer still hears. Named by the
# classes to be RAISED rather than the one to be muffled, so a warning added
# later is silent until it is decided about (D088, and the paragraph above
# program_status()'s own use of this). One vector rather than two literals,
# because has_encoder() muffles on the same partition and for the same reason,
# and two copies drift.
tm_audible_location_classes <- function() {
c(
"tidymedia_location_gone",
"tidymedia_location_unreadable",
# Added at M138, decided rather than inherited: a file that is there and
# cannot be run is a file on this machine the caller can repair, which is
# exactly the test D088 states for being audible.
"tidymedia_location_not_executable"
)
}
# A resolved location or a captured version, as one string or NA. NULL is what
# find_program() returns when it finds nothing; "" is what Sys.which() returns.
tm_na_string <- function(x) {
if (is.null(x) || length(x) != 1L || is.na(x) || !nzchar(x)) {
return(NA_character_)
}
as.character(x)
}
# find_ffmpeg() -----------------------------------------------------------
# First of the four wrappers on purpose: roxygen names a merged topic, and the
# "Other program management functions" links that point at it, after the
# first block it reads, so this page is ?find_ffmpeg rather than
# ?find_mediainfo.
#' Find the location of a dependency program
#'
#' @description
#' Each of these functions returns the location of one program as a string:
#' [find_ffmpeg()], [find_ffprobe()], [find_ffplay()] and [find_mediainfo()].
#'
#' @details
#' The function looks on the `PATH` first. If the program is not there, the
#' function reads the location that [set_program()] saved. That location is in
#' a file under `tools::R_user_dir("tidymedia", "config")`. If neither place has
#' the program, the function gives a warning and returns `NULL`.
#'
#' @section Problems with a saved location:
#' A saved location that no longer works gives a warning, and the function
#' returns `NULL`. The warning has one of three classes that you can catch:
#'
#' * `tidymedia_location_gone`: the function read the location, but no program
#' is there now. The condition holds the program name in `tm_program` and the
#' location in `tm_location`, as the file holds it.
#' * `tidymedia_location_not_executable`: a file is at the saved location, and
#' it cannot be run. Give it permission to execute, or use the `set_*()`
#' function to point at another copy. The condition holds the program name in
#' `tm_program` and the location in `tm_location`, as the file holds it.
#' This case does not arise
#' on Windows, which has no execute bit. There a location is read the way R's
#' own `Sys.which()` reads it, except that a folder never counts and a
#' leading `~` is expanded.
#' * `tidymedia_location_unreadable`: the file does not hold exactly one
#' location. It is empty, has more than one line, or has one empty line. The
#' condition holds the program name in `tm_program` and the file in
#' `tm_file`.
#'
#' A line that holds only spaces counts as a location. So it gives
#' `tidymedia_location_gone`, not `tidymedia_location_unreadable`.
#'
#' To fix any of the three, forget the location with [unset_program()], or
#' replace it with [set_program()]. A file that cannot be run can also be fixed
#' by giving it permission to execute.
#'
#' @section Locations saved by earlier versions:
#' Versions of the package before 0.2.0 saved locations in a different folder,
#' `rappdirs::user_config_dir("tidymedia", "R")`. The functions still read a
#' file in that folder. They read it only when the current folder has no file
#' for the program. [program_status()] looks in the same places, in the same
#' order.
#'
#' A `tidymedia_location_unreadable` warning names the file that the function
#' read. That file can be the one in the old folder.
#'
#' [set_program()] writes to the current folder only. After it writes a file
#' for a program, the old file for that program is not read.
#'
#' [unset_program()] removes the file in both folders. So the old location does
#' not come back after the current file is gone.
#'
#' @return The location of the program as a string, or `NULL` when it could
#' not be found.
#' @seealso [set_program()] to save the location of a program that is not on
#' the `PATH`, and [install_on_win()] to download FFmpeg on Windows.
#' @family program management functions
#' @examplesIf nzchar(Sys.which("ffmpeg")) && nzchar(Sys.which("mediainfo"))
#' # Returns the path to the binary, or NULL with a warning if it is not found
#' find_ffmpeg()
#' find_mediainfo()
#' @export
find_ffmpeg <- function() {
find_program("ffmpeg")
}
# find_mediainfo() --------------------------------------------------------
#' @rdname find_ffmpeg
#' @export
find_mediainfo <- function() {
find_program("mediainfo")
}
# find_ffprobe() -----------------------------------------------------------
#' @rdname find_ffmpeg
#' @export
find_ffprobe <- function() {
find_program("ffprobe")
}
# find_ffplay() -----------------------------------------------------------
#' @rdname find_ffmpeg
#' @export
find_ffplay <- function() {
find_program("ffplay")
}
# set_program() ------------------------------------------------------------
#' Set the location of a dependency program
#'
#' @description
#' `set_program()` saves the location of a program, so the package can find it
#' in later sessions. `set_ffmpeg()`, `set_ffprobe()`, `set_ffplay()` and
#' `set_mediainfo()` do the same for one program each.
#'
#' The location goes in a file named after the program, such as
#' `ffmpeg_location.txt`. The file is under
#' `tools::R_user_dir("tidymedia", "config")`. [find_ffmpeg()] and the other
#' `find_*()` functions read it when the program is not on the `PATH`.
#'
#' @details
#' The file stays after the session ends, so the call asks you to confirm
#' first. It writes nothing until you agree. The question shows the location
#' the call writes, which is the location as you typed it. The one change is a
#' leading `~`, which is written as your home folder in full. The question
#' also shows the full path of the file. If you say no, the call changes
#' nothing.
#'
#' In a session where no one can answer, the call gives an error. Pass
#' `confirm = FALSE` to write without the question, for example in a script
#' that runs on its own.
#'
#' For locations saved by versions before 0.2.0, see the section "Locations
#' saved by earlier versions" in [find_ffmpeg()].
#'
#' @param program A string naming the program to set the location for.
#' @param location A string with the location of the program.
#' @param confirm Whether to ask before the call writes the location. `TRUE`,
#' the default, asks. In a session where no one can answer, `TRUE` gives an
#' error. `FALSE` writes without asking.
#' @return Invisibly, `TRUE` when the call wrote the location and `FALSE` when
#' you said no.
#'
#' @seealso [find_ffmpeg()] and the other `find_*()` functions to find a
#' program, and [install_on_win()] to download FFmpeg on Windows.
#' @family program management functions
#' @examples
#' \dontrun{
#' # Point tidymedia at a binary in a non-standard location; asks first
#' set_mediainfo("C:/Program Files/MediaInfo/mediainfo.exe")
#'
#' # In an unattended script, where there is no one to ask
#' set_mediainfo("C:/Program Files/MediaInfo/mediainfo.exe", confirm = FALSE)
#' }
#' @export
set_program <- function(program = c("ffmpeg", "ffprobe", "ffplay", "mediainfo"),
location, confirm = TRUE) {
tm_set_program(program, location, confirm = confirm,
call = rlang::current_env())
}
# tm_set_program(): set_program()'s body, with `call` threaded.
#
# The split exists so `call` stops appearing in an exported signature and in
# the Rd usage line a reader copies from: it is the environment a refusal is
# reported from, which only tidymedia's own front doors have a value for
# (M112, superseding M110's decision to leave it a published formal).
#
# Every refusal below carries `call` -- the argument checkers included, not
# only the abort sites (M100). D074's siting has each export refuse its own
# arguments; this seam reaches the same outcome by naming the frame, because
# the not-found and consent refusals sit below the config-path resolution and
# the prompt build and so cannot be re-called at five front doors without
# duplicating both bodies (M110).
#
# `call` carries no default. Every caller is one of the five exports below and
# has its own frame to pass; a default would hand a site that forgot it this
# helper's frame, which is the blame the split exists to remove.
tm_set_program <- function(program = c("ffmpeg", "ffprobe", "ffplay", "mediainfo"),
location, confirm, call) {
program <- rlang::arg_match(program, error_arg = "program", error_call = call)
rlang::check_string(location, call = call)
rlang::check_bool(confirm, call = call)
# Expanded where the location is BUILT, so what is checked, shown in the
# prompt and written is the path the spawners will run. `run_program()`,
# `ffprobe()` and `mediainfo()` quote it, and the shell does not expand a
# quoted `~` (D098). A bare name has no `~` to expand and is written as typed.
location <- path.expand(location)
# Asked of the filesystem for a path and of `PATH` for a bare name, which is
# the split tm_program_state() exists for: a bare name is what a caller who
# typed `set_ffmpeg("ffmpeg")` means, and a path is a file this machine can
# answer about without asking `which` (M138).
if (!tm_runnable_program(location)) {
cli::cli_abort("Can't find an executable at {.file {location}}.",
class = "tidymedia_program_not_found", call = call,
tm_program = program, tm_location = location)
}
# Find where to save user configuration data (tools::R_user_dir(), M097)
config_dir <- tm_config_dir()
config_file <- tm_config_file(program, config_dir)
# Consent comes before anything is created or written, so a decline -- and a
# refusal for want of anyone to ask -- leaves the config directory exactly as
# it was found. The refusal names the same two items the prompt would have,
# and the escape hatch by the argument's own name (M38/M40: the seam carries
# no argument name of its own).
if (confirm) {
details <- tm_set_details(program, location, config_dir)
approved <- tm_confirm(
tm_set_prompt(program, location, config_dir),
call = call,
"i" = "Pass {.code confirm = FALSE} to set the location without being asked.",
"i" = tm_cli_escape(details[[1]]),
"i" = tm_cli_escape(details[[2]])
)
if (!approved) return(invisible(FALSE))
}
# Create configuration directory if needed
if (!dir.exists(config_dir)) dir.create(config_dir, recursive = TRUE)
# Save location to user configuration file
writeLines(location, config_file)
# Pointing at a different binary invalidates everything remembered about the
# old one, so the session memo goes with it (M67/D044). Unconditional across
# all four programs: cheap, and it cannot be forgotten when a second
# capability memo is added later.
forget_ffmpeg_capabilities()
invisible(TRUE)
}
# set_mediainfo() ---------------------------------------------------------
#' @rdname set_program
#' @export
set_mediainfo <- function(location, confirm = TRUE) {
tm_set_program("mediainfo", location, confirm = confirm,
call = rlang::current_env())
}
# set_ffmpeg() ------------------------------------------------------------
#' @rdname set_program
#' @export
set_ffmpeg <- function(location, confirm = TRUE) {
tm_set_program("ffmpeg", location, confirm = confirm,
call = rlang::current_env())
}
#' @rdname set_program
#' @export
set_ffprobe <- function(location, confirm = TRUE) {
tm_set_program("ffprobe", location, confirm = confirm,
call = rlang::current_env())
}
#' @rdname set_program
#' @export
set_ffplay <- function(location, confirm = TRUE) {
tm_set_program("ffplay", location, confirm = confirm,
call = rlang::current_env())
}
# unset_program() ---------------------------------------------------------
#' Forget the location of a dependency program
#'
#' @description
#' `unset_program()` forgets the location that [set_program()] saved for a
#' program. After that, [find_ffmpeg()] and the other `find_*()` functions look
#' for the program on the `PATH` only.
#'
#' @details
#' The call deletes the file that holds the location. It does not ask you to
#' confirm first. It does not remove the program, and it does not change the
#' `PATH`. A program on the `PATH` is still found afterwards.
#'
#' If no location is saved for the program, the call gives a warning and
#' returns `FALSE`. It does not give an error, because the program is already
#' forgotten.
#'
#' The call also clears a location saved by a version before 0.2.0. See the
#' section "Locations saved by earlier versions" in [find_ffmpeg()].
#'
#' @param program A string naming the program to forget. One of `"ffmpeg"`,
#' `"ffprobe"`, `"ffplay"` or `"mediainfo"`. There is no default, because the
#' call deletes a file. A call that names no program gives an error.
#' @return Invisibly, `TRUE` when the call removed a saved location, and `FALSE`
#' when there was none to remove.
#' @seealso [set_program()] to save a location, and [program_status()] to see
#' where the package finds each program.
#' @family program management functions
#' @examples
#' \dontrun{
#' # Forget a location set_program() remembered, so that find_mediainfo() goes
#' # back to answering from the PATH
#' unset_program("mediainfo")
#' }
#' @export
# `program` has no default: the call deletes a file, and D079's rule keeps a
# member of the set out of the default position, so a call that names no
# program refuses rather than picking one.
unset_program <- function(program) {
tm_unset_program(program, call = rlang::current_env())
}
# tm_unset_program(): unset_program()'s body, with `call` threaded, for the
# reason M112 records at tm_set_program() -- `call` names the environment a
# refusal is reported from and has no business in an exported signature or in
# the Rd usage line a reader copies from. It carries no default here either.
tm_unset_program <- function(program, call) {
program <- rlang::arg_match(
program,
values = tm_programs(),
error_arg = "program",
error_call = call
)
# Both files, in the order find_program() reads them. Deleting only the
# current one would leave a pre-0.2.0 location that find_program() then
# starts answering with, which is the opposite of forgetting.
files <- c(
tm_config_file(program),
tm_config_file(program, tm_legacy_config_dir())
)
present <- files[file.exists(files)]
if (!length(present)) {
cli::cli_warn(
c(
"No remembered location to forget for {program}.",
"i" = "Use {.fn set_{program}} to remember one."
),
class = "tidymedia_no_remembered_location",
tm_program = program,
call = call
)
return(invisible(FALSE))
}
# Through the tm_unlink() seam, and answered by a third look at the
# filesystem rather than by the removal's own return value: `unlink()`
# reports one status for the whole call and no names, so which files are
# still there is a question only the filesystem can answer (M103's shape).
tm_unlink(present)
left <- present[file.exists(present)]
# What tidymedia remembers about an FFmpeg build was memoized against the
# binary the forgotten location named, so it goes with it -- the same reason
# set_program() drops it when it points at a different binary (M67/D044).
#
# Above the abort, not below it, and keyed on any removal that took rather
# than on all of them. A partial removal -- one file gone, the other still
# there -- changes which location find_program() answers with, so the memo is
# already stale by the time the refusal is raised; dropping it only on the
# success path left it describing a build the lookups had stopped resolving
# to (D089). A run that removed nothing skips it: nothing about the resolved
# binary changed, and a discard there costs a re-probe for no reason.
if (length(left) < length(present)) forget_ffmpeg_capabilities()
if (length(left)) {
cli::cli_abort(
c(
"Can't remove the remembered location for {program}.",
"x" = "{.file {left}}",
"i" = "Check that you can write to the directory holding it."
),
class = "tidymedia_location_not_removed",
tm_program = program,
tm_files = left,
call = call
)
}
invisible(TRUE)
}
# run_program() -----------------------------------------------------------
# Run a resolved program with an argument vector and return its stdout as a
# character vector. Every token in `args` is passed through `shQuote()` so the
# CLI receives arguments verbatim without shell interpolation: file paths and
# MediaInfo `--Inform` strings containing spaces, quotes, `;`, `%`, or `$` are
# safe. This is the internal counterpart to the Layer 0 escape hatches
# `mediainfo()` / `ffprobe()`, which stay raw-string by design (D002); the
# structured readers build clean token vectors and route them through here.
#
# Callers pass tokens unquoted (one vector element per CLI argument); do not
# pre-quote. `location` is the output of a `find_*()` call; a missing binary
# aborts rather than shelling out to nothing.
#
# `input` and `stderr` pass through to system2(): ffm_run() sets `input = ""`
# so FFmpeg cannot drain the parent's stdin (see ffmpeg()) and `stderr = ""`
# so encode progress/errors stream to the console; the metadata readers keep
# the quiet defaults (stderr discarded).
run_program <- function(location, args, program = "the program",
input = NULL, stderr = FALSE,
call = rlang::caller_env()) {
if (is.null(location) || is.na(location) || !nzchar(location)) {
cli::cli_abort("Could not locate {program}.", call = call)
}
# shQuote()'s default is sh-style quoting on every OS; on Windows the child
# process parses its command line with cmd-style (double-quote) rules, so
# the type must follow the platform or spaced paths break there.
quote_type <- if (.Platform$OS.type == "windows") "cmd" else "sh"
# `suppress = TRUE` reproduces the suppressWarnings() this replaces: every
# caller here has always seen warnings discarded, and the timeout is the only
# new thing that reaches them (M69/D047). The limit is 0 -- no limit, byte-for
# -byte today's behavior -- unless the caller set `tidymedia.timeout`.
limit <- resolve_timeout(call = call)
guard_timeout(
program, limit,
system2(location, args = shQuote(args, type = quote_type), stdout = TRUE,
stderr = stderr, input = input, timeout = limit),
suppress = TRUE,
call = call
)
}
# tm_confirm() ------------------------------------------------------------
# Ask the caller to approve an action before it happens, and refuse rather
# than assume consent where there is no one to ask (D080). The seam carries no
# caller's argument name of its own: the caller supplies the bullets that name
# its own escape hatch, so a second caller whose hatch is spelled differently
# inherits no wrong hint (M38/M40).
#
# `prompt` arrives already formatted -- the caller runs its own values through
# cli, so a path containing braces cannot be re-interpolated here (M44).
#
# `rlang::is_interactive()` decides whether anyone can be asked; it honors
# `rlang::local_interactive()` and the `rlang_interactive` option, which is how
# the suite reaches the refusal branch. `utils::menu()` gates on
# `base::interactive()` instead, which those do not move, so the ask branch is
# reachable in a test only through a mock of `menu()`.
tm_confirm <- function(prompt, ..., call = rlang::caller_env()) {
refuse <- function() {
cli::cli_abort(
c("Can't ask for confirmation in a non-interactive session.", ...),
class = "tidymedia_confirmation_unavailable",
call = call
)
}
if (!rlang::is_interactive()) refuse()
# The two predicates can disagree: `rlang::is_interactive()` honors the
# `rlang_interactive` option, `base::interactive()` does not, and `menu()`
# refuses on the latter. A caller who has set that option in a session with
# no console reaches this line and would otherwise get menu()'s own
# unclassed error, with none of the refusal's information; routing that
# failure through refuse() keeps one contract for "no one can be asked".
answer <- tryCatch(
utils::menu(c("Yes", "No"), title = prompt),
error = function(cnd) refuse()
)
# menu() returns 0 when the reader answers nothing, which is a decline.
answer == 1L
}
# Make a string safe to hand to cli as message text: cli interpolates `{...}`
# in every bullet, so a path containing braces is escaped rather than
# evaluated (M44).
tm_cli_escape <- function(x) {
gsub("}", "}}", gsub("{", "{{", x, fixed = TRUE), fixed = TRUE)
}
# Platform ------------------------------------------------------------------
# Which operating system this session is running on, as a lowercase name.
#
# The seam `install_on_win()`'s platform gate reads, and the only place the
# package asks. `Sys.info()` is the source: its `sysname` is the kernel name
# uname reports -- `Windows`, `Darwin`, `Linux`, `FreeBSD`, `SunOS` -- which
# is what a message naming the caller's platform wants, and lowercasing it is
# the whole normalization. R documents `Sys.info()` as possibly unimplemented,
# in which case it returns `NULL`, so `.Platform$OS.type` is the fallback: it
# is always one of `windows` or `unix`, which is coarser than the gate would
# like but still answers the one question the gate asks. A host that cannot
# say more than `unix` is refused like any other non-Windows host and gets no
# package-manager route, only `set_program()` (M108's gate).
#
# Both sources are arguments so the fallback branch can be fired by a test: on
# every machine the suite runs on, `Sys.info()` is implemented, so a branch
# reading it directly would be unreachable code. No caller passes either one.
tm_os <- function(info = Sys.info(), os_type = .Platform$OS.type) {
if (is.null(info)) {
return(tolower(os_type))
}
tolower(info[["sysname"]])
}
# install_on_win() helpers ------------------------------------------------
# The programs an install registers, and where the extracted build puts each
# one. Named once so the prompt cannot promise a different set of writes from
# the one the call makes.
tm_install_registers <- c("ffmpeg", "ffprobe", "ffplay")
# The install offer, or nothing. `install_on_win()` is named only where it runs
# and only for the programs it registers: on a Mac, or for mediainfo, it would
# send the caller at a call that refuses them. Both facts are read from the
# installer's own seam and its own list rather than restated at a warning site,
# so the advice cannot drift from what the installer does (M115). One function
# rather than a condition repeated at each site, because find_program() offers
# the same recovery from two branches -- a program it never found, and a
# remembered location whose binary is gone -- and a second copy of the test is
# how the two would come to disagree (M116).
#
# Returns a cli bullet named "i", or a zero-length vector that `c()` splices
# away. The string carries cli inline markup and is interpolated in the calling
# warning's frame, not here.
tm_install_bullet <- function(program) {
if (identical(tm_os(), "windows") && program %in% tm_install_registers) {
return(c("i" = "Or run {.fn install_on_win} to download FFmpeg and \\
remember where it landed."))
}
character(0)
}
# Where a caller who is not on Windows gets FFmpeg instead. One line each,
# because that is the whole of the answer on those two platforms, and the
# package keeps no installer for either (GP1: each would need its own source,
# digest format and architecture matrix). A platform not named here -- FreeBSD,
# Solaris, or the coarse `unix` the seam falls back to -- is still refused, and
# is told only to point tidymedia at a build it already has: the package has no
# idea what that machine's package manager is, and advice it cannot stand
# behind is worse than none.
tm_install_routes <- c(
darwin = "brew install ffmpeg",
linux = "sudo apt-get install ffmpeg"
)
# The name a caller would recognize, for the uname words that are not one.
# `tm_os()` speaks uname's vocabulary, which is what the condition carries and
# what a bug report wants; a macOS caller told only "darwin" has to know that
# is their machine. Platforms whose uname word is already the familiar name --
# `linux`, `windows`, `freebsd` -- are absent and get no parenthetical.
tm_os_names <- c(
darwin = "macOS",
sunos = "Solaris"
)
# Of those three, the two the package itself calls. `ffplay` is reachable only
# through find_ffplay()/set_ffplay() and nothing in tidymedia invokes it, so a
# build that omits it is an install worth completing; a build with no `ffmpeg`
# or no `ffprobe` is not (M102 AC4).
tm_install_required <- c("ffmpeg", "ffprobe")
# The build install_on_win() fetches when the caller names no URL. Named once
# so the sidecar decision compares against the same string the download uses,
# rather than against a second copy of it that could drift.
tm_default_download_url <-
"https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.7z"
# Where a source publishes its archive's digest: the archive URL with
# `.sha256` appended, which is what gyan.dev writes beside every build.
tm_sidecar_url <- function(download_url) {
paste0(download_url, ".sha256")
}
# Pull a SHA-256 out of a published sidecar body. Three spellings are in the
# wild and all three are accepted: a bare digest on its own, `sha256sum`'s
# "<hex> <file>" pair, and OpenSSL's "SHA256(<file>)= <hex>". Hex case is not
# significant in any of them, so the digest comes back lowercased and every
# comparison downstream can be a plain identical().
#
# Returns NULL where no line matches, rather than aborting. Every helper this
# milestone adds returns instead of aborting, deliberately: install_on_win()
# raises all of its classed refusals in its own body, which is what makes the
# census AC6 derives from that body's exits exactly true rather than nearly so.
tm_parse_sidecar <- function(lines) {
if (is.null(lines)) return(NULL)
patterns <- c(
# A digest alone, or first of two whitespace-separated fields.
"^[[:space:]]*([0-9a-fA-F]{64})([[:space:]]|$)",
"[Ss][Hh][Aa]256[[:space:]]*\\([^)]*\\)[[:space:]]*=[[:space:]]*([0-9a-fA-F]{64})"
)
for (line in lines) {
for (pattern in patterns) {
found <- regmatches(line, regexec(pattern, line))[[1]]
if (length(found)) return(tolower(found[[2]]))
}
}
NULL
}
# The SHA-256 of a file on disk. `digest` reads the file in chunks rather than
# into memory, which matters for a several-hundred-megabyte build (D081).
# Returns NULL where the file cannot be read. `digest()` aborts with a bare
# simpleError on a path that is not there, which would leave install_on_win()
# through an exit carrying no class of its own -- and one AC6's census cannot
# see, since it is neither a return() nor a cli_abort() node.
tm_archive_digest <- function(path) {
tryCatch(
tolower(digest::digest(path, algo = "sha256", file = TRUE)),
error = function(cnd) NULL
)
}
# Fetch `url` to `destfile`. `utils::download.file()`'s contract allows two
# shapes of failure -- signalling a condition of its own, and returning a
# non-zero status -- and this covers both in one value: TRUE where the file
# arrived, the signalled condition where there was one, and NULL where the
# status was non-zero and nothing was signalled. The caller turns anything but
# TRUE into its own classed refusal, retaining the condition as `parent` where
# one exists.
tm_fetch <- function(url, destfile) {
tryCatch(
{
status <- utils::download.file(url = url, destfile = destfile, mode = "wb")
# Status 0 is not a promise that a readable file arrived: a mirror can
# report success and leave nothing behind, and the caller downstream
# would then meet a bare simpleError from whatever reads the file. The
# existence test is what makes the TRUE above mean what this comment
# says it means.
if (identical(as.integer(status), 0L) && file.exists(destfile)) {
TRUE
} else {
NULL
}
},
error = function(cnd) cnd
)
}
# Every entry under `dir`, recursively, as a data frame of path, size and
# mtime -- the three fields D046's created-or-changed comparison reads. Paths
# are relative to `dir` so two snapshots of the same directory compare
# directly, directories are included because a failed extraction creates them
# as readily as it creates files, and dotfiles are included because a caller's
# hidden file is as much theirs as a visible one.
#
# Size and time come from one `file.info()` stat per entry, and the frame is
# ordered by path so a comparison never turns on the order the filesystem
# happened to list.
#
# An entry `file.info()` cannot stat -- a broken symlink is the reachable
# case, measured 2026-09-02 -- comes back NA in every field. The NAs are kept
# rather than filled in here: this frame is a record of what was seen, and two
# NAs compare identical, so an unstattable entry the caller already had reads
# as unchanged, which is right. Deciding what an NA MEANS is the comparison's
# job, and tm_snapshot_added() does it (M103).
tm_dir_snapshot <- function(dir) {
paths <- sort(list.files(
dir,
recursive = TRUE, all.files = TRUE, include.dirs = TRUE, no.. = TRUE
))
info <- file.info(file.path(dir, paths), extra_cols = FALSE)
data.frame(
path = paths,
size = as.numeric(info$size),
mtime = as.numeric(info$mtime),
isdir = as.logical(info$isdir),
stringsAsFactors = FALSE
)
}
# What the two snapshots show this extraction added, split into the two sets
# the removal treats differently.
#
# A DIRECTORY qualifies only where it is new -- where nothing was at its path
# before, or what was there was not a directory -- and only the topmost new
# one of a chain: a pre-existing directory's mtime moves the instant an entry
# lands inside it -- measured 2026-09-02, 19:13:23.663 before and 19:13:24.848 after
# one file was written into it -- so a merely-changed directory removed
# recursively would take the caller's own untouched entries with it. Its added
# children are removed one by one instead, which reaches the same debris
# without the collateral (M103).
#
# A FILE qualifies where it is new OR where its size or mtime moved, which is
# D046's rule unchanged: the zero-byte truncation is the case that rule exists
# to clear, and a pre-existing file the extraction overwrote is a file this
# run wrote.
tm_snapshot_added <- function(before, after) {
# An entry `file.info()` could not stat carries an NA `isdir`, and NA is the
# one value the two subscripts below cannot take: `x[NA]` selects
# NA_character_, which is neither a path the removal can delete nor a path
# the report can name, so such an entry would be silently dropped by both.
# It counts as a file, which is both what a broken symlink is on disk and
# the safe reading -- a file is removed by name, where a directory would be
# removed with everything under it (M103).
isdir <- !is.na(after$isdir) & after$isdir
# A directory is CREATED where it did not exist AS A DIRECTORY before, not
# merely where its path is new. The two readings differ on one case and it
# is a reachable one: a path the caller held as a file and this extraction
# replaced with a directory is in `before`, so the path-only reading calls
# it pre-existing, while its `isdir` keeps it out of the file bucket -- so
# it lands in neither, and is neither removed nor named. The type is what
# the removal turns on, so the type is what the comparison asks about
# (M103).
was_dir <- !is.na(before$isdir) & before$isdir
prior_dir <- after$path %in% before$path[was_dir]
# One `match()` over the whole column rather than one per row: the per-row
# form rebuilt the hash table on every iteration, measured at 0.50 s on 6000
# unchanged entries, and this frame is walked three times per removal sweep.
j <- match(after$path, before$path)
same <- function(x, y) {
eq <- x == y
ifelse(is.na(eq), is.na(x) & is.na(y), eq)
}
# A type change counts alongside size and mtime, so a directory that became
# a file is a changed file even where the two happen to stat alike.
changed <- is.na(j) |
!same(after$size, before$size[j]) |
!same(after$mtime, before$mtime[j]) |
!same(after$isdir, before$isdir[j])
created_dirs <- after$path[isdir & !prior_dir]
list(
files = after$path[changed & !isdir],
dirs = created_dirs[!(dirname(created_dirs) %in% created_dirs)]
)
}
# A thin wrapper over `unlink()`, a seam of its own so the suite can make a
# removal fail without making the filesystem fail. `expand = FALSE` is D046's
# rule: a name holding `*`, `?` or `[` costs no neighbour.
tm_unlink <- function(path, recursive = FALSE) {
unlink(path, recursive = recursive, expand = FALSE)
}
# Remove what the extraction added under `dir`, and name what would not go.
#
# Directories first and recursively, so the deep chain a stripped absolute
# path produces costs one call rather than one per level; then the added files
# that are still there, which is every file outside a removed directory. A
# third snapshot decides what survived: `unlink()` reports one status for a
# whole subtree and no names at all, so the only honest answer to "what is
# still there" is another look at the directory. Only entries the removal
# TARGETED can be reported -- an untouched file the caller put there is not a
# leftover (M103).
tm_remove_added <- function(dir, before, after) {
added <- tm_snapshot_added(before, after)
targeted <- c(added$dirs, added$files)
if (!length(targeted)) return(character(0))
sweep <- function() {
still <- tm_dir_snapshot(dir)$path
for (path in added$dirs) {
if (path %in% still) tm_unlink(file.path(dir, path), recursive = TRUE)
}
still <- tm_dir_snapshot(dir)$path
kept_dirs <- added$dirs[added$dirs %in% still]
for (path in added$files) {
if (!(path %in% still)) next
# A file under a created directory the recursive call could not remove
# is left where it is. Two reasons, and either alone would be enough:
# it is already covered by that directory's own name in the report, and
# the path may not resolve where it looks like it does. `list.files()`
# descends THROUGH a directory symlink, so a symlink the extraction
# created reads as a created directory whose children are the link
# target's -- outside this destination entirely. The recursive
# `unlink()` on the link removes the LINK, so on a platform that can
# delete it there is nothing left to walk; where that call fails, this
# loop would otherwise delete the target's files, outside the directory
# and unnamed (M103 review pass 3).
if (any(startsWith(path, paste0(kept_dirs, "/")))) next
tm_unlink(file.path(dir, path))
}
sort(targeted[targeted %in% tm_dir_snapshot(dir)$path])
}
survived <- sweep()
# A second pass, and only where the first left something. Windows will not
# delete a file another handle still holds, and the handle in question is
# the one `archive_extract()` was writing the failed entry through: it is
# not an R connection, so nothing here can close it by name. `gc()` is the
# one lever R has -- it runs the finalizers of any external pointer the
# extraction left unreferenced -- and the pause is for a hold that is
# transient rather than leaked, which a scanner or an indexer produces.
# Both are free on the succeeding path, which never reaches this function,
# and on the platforms where the first sweep already worked.
if (length(survived)) {
gc()
Sys.sleep(0.1)
survived <- sweep()
}
survived
}
# The chain of directories `dir.create(path, recursive = TRUE)` would have to
# make for `path` to exist, outermost first, or none where it already does.
#
# Read BEFORE the create rather than after, because after is too late to tell
# a directory the call made from one it found -- and a partially successful
# recursive create, which makes the parents and then fails on the leaf, leaves
# a state no later look can attribute.
tm_missing_ancestors <- function(path) {
out <- character(0)
path <- normalizePath(path, winslash = "/", mustWork = FALSE)
while (!dir.exists(path)) {
out <- c(path, out)
parent <- dirname(path)
if (identical(parent, path)) break
path <- parent
}
out
}
# Remove the directories `tm_missing_ancestors()` named, deepest first, and
# stop at the first that is not empty. Returns the ones still standing, so a
# caller building a message can say what this call created and could not take
# back rather than guess from `dir.exists()` alone (M103 review pass 3).
#
# Emptiness is the guard rather than a record of what was written: a directory
# the call created but something else has since put a file in is not the
# call's to delete, and stopping rather than skipping is what keeps the
# removal from reaching around a kept directory to its parent.
tm_remove_created_dirs <- function(dirs) {
remaining <- dirs
for (dir in rev(dirs)) {
if (dir.exists(dir)) {
if (length(list.files(dir, all.files = TRUE, no.. = TRUE))) break
if (!identical(tm_unlink(dir, recursive = TRUE), 0L)) break
}
remaining <- setdiff(remaining, dir)
}
invisible(remaining)
}
# Unpack `archive` into `dir`. A three-slot list: `files`, the paths the
# extraction produced relative to `dir`, or NULL where libarchive refused;
# `leftovers`, the entries a refused extraction wrote and the cleanup could
# not remove; and `removed_yours`, the entries the caller already had that
# the cleanup removed because the failed extraction had written over them.
#
# The list replaces the bare NULL M102 returned because a failed unpack now
# has two things to say rather than one. R drops attributes on NULL, so
# carrying the leftovers alongside a NULL return was never open; the caller
# tests `is.null(produced$files)` instead (M103).
#
# The file list is returned rather than dropped because it is the only honest
# answer to what THIS extraction produced: `archive_extract()` writes into a
# directory it does not clear, so a later look at `dir` sees a previous
# install's files as readily as this one's (M102 AC4).
#
# The condition is dropped rather than returned, which is the one place this
# milestone deliberately loses information: libarchive's message is a C++
# source location and an internal function name (`archive_extract.cpp:140
# archive_read_open1(): Unrecognized archive format`), which tells the reader
# of an R error nothing they can act on. The caller's refusal names the archive
# and the directory instead (M102 AC3).
tm_unpack <- function(archive, dir) {
# The connection is opened here rather than left to `archive_extract()`,
# which opens `file(archive, "rb")` itself and closes it only on the paths
# that reach the end of the read. A libarchive failure inside
# `archive_read_data_block()` leaves it open -- measured 2026-09-02, one
# leaked connection for the corrupt-payload fixture, none for the
# unrecognized-format one and none on the succeeding path -- and Windows
# will not delete a file something still holds open, so that leak is what
# left the downloaded archive behind after a failed unpack (M102 AC3).
con <- tryCatch(file(archive, "rb"), error = function(cnd) NULL)
if (is.null(con)) {
return(list(files = NULL, leftovers = character(0), removed_yours = character(0)))
}
on.exit(tm_close(con), add = TRUE)
before <- tm_dir_snapshot(dir)
produced <- tryCatch(
as.character(archive::archive_extract(con, dir = dir, strip_components = 1)),
error = function(cnd) NULL
)
if (!is.null(produced)) {
return(list(
files = produced, leftovers = character(0), removed_yours = character(0)
))
}
# The connection is closed BEFORE the removal rather than left to the exit
# handler: it is the archive's, not the destination's, but the same rule
# that made it worth owning -- Windows will not delete a file something
# still holds open -- says to hold no handle the cleanup does not need. The
# handler stays, so on this path the connection has two closers: the second
# one is a no-op, because `tm_close()` guards on `isOpen()` inside a
# `tryCatch()`. Two closers rather than one is the price of closing early
# without also having to prove no path below reaches the handler; M102 AC3's
# "leaves no connection open" is what both of them serve.
tm_close(con)
after <- tm_dir_snapshot(dir)
leftovers <- tm_remove_added(dir, before, after)
# Which of the caller's OWN entries the cleanup removed. D082 removes a
# pre-existing file the failed extraction wrote over -- what it holds
# afterwards is nothing the caller put there -- and that is the one
# deletion a refusal cannot describe as leaving the directory as it found
# it. Read off the destination rather than predicted: an entry that was
# there before and is not there now is one this call took (M103 review
# pass 3).
yours <- intersect(tm_snapshot_added(before, after)$files, before$path)
list(
files = NULL,
leftovers = leftovers,
removed_yours = setdiff(yours, tm_dir_snapshot(dir)$path)
)
}
# Which of `programs` the extraction produced, read off the file list
# `tm_unpack()` returned rather than off the install directory. The paths are
# matched where the install will look for them -- `bin/<program>.exe` under
# the install directory, which is what tm_install_binary() builds -- with
# separators normalized, because a path is what libarchive reports and the
# comparison should not turn on which slash it used, and case folded, because
# the target filesystem does not distinguish them either.
tm_extracted_programs <- function(files, programs) {
found <- tolower(sub("^[.]/", "", gsub("\\\\", "/", as.character(files))))
programs[tolower(paste0("bin/", programs, ".exe")) %in% found]
}
# Which of the paths `tm_unpack()` reported are under `dir` now. The file list
# is what libarchive says it wrote, and it is not the same question as what is
# there: a build the antivirus quarantines between the extraction and the
# check is listed and gone. Everything below the extraction asks THIS set
# rather than the list, so one meaning of "produced" holds across the whole
# path and no refusal describes a directory it has not looked at (M105).
# Separators are normalized the way `tm_extracted_programs()` normalizes them.
# Case is NOT: that helper folds case to match a reported path against a
# program name, which is a comparison between two strings, while this one asks
# the filesystem, which answers for the target's own rules -- case-insensitive
# on Windows, the only platform this runs on (M105 review F12).
tm_files_on_disk <- function(files, dir) {
rel <- sub("^[.]/", "", gsub("\\\\", "/", as.character(files)))
rel[file.exists(file.path(dir, rel))]
}
# Close `con` where it is still open, and do nothing where the callee already
# closed it: `isOpen()` itself errors on a connection that has been destroyed,
# so the test and the close share one handler.
tm_close <- function(con) {
invisible(tryCatch(if (isOpen(con)) close(con), error = function(cnd) NULL))
}
# `path.expand()` where the path is built (M104 review F1, D083). Before M138
# the check asked `Sys.which()`, which does not expand `~`, while `file.info()`
# does, so `install_on_win(install_dir = "~/ffmpeg")` refused a good build and
# blamed the archive. `set_program()` now expands too (D098), so this call is
# no longer the only guard, but it keeps every path the install reports in the
# form that is written.
tm_install_binary <- function(install_dir, program) {
path.expand(file.path(install_dir, "bin", paste0(program, ".exe")))
}
# Whether a path the extraction produced can be handed to set_program(): it
# answers the way set_program() itself will ask (`tm_program_state()`, at the
# abort this check exists to make unreachable from here), and it holds bytes.
# Being a file rather than a directory is part of that one question now, which
# is why this body no longer asks it twice (M138/D096).
#
# The non-empty test is what the predicate cannot promise on the one platform
# this install runs on: Windows has no execute bit, so a truncated `ffmpeg.exe`
# of zero length answers "runnable" there and would be remembered as a working
# program (D083). The predicate decides the rest, so the directory and absent
# forms are refused there and all three forms `tidymedia_program_unusable`
# partitions still reach it (D084). The one caller passes paths the install
# built, which name the file itself. Two forms it never passes are read
# differently by the predicate and the size test: a bare name found on `PATH`
# (below), and an extensionless Windows path the predicate finds with `.exe`
# appended, where the size test reads whatever is at the bare path. A directory is refused by the predicate's
# not-a-directory test alone, because `file.access(dir, 1L)` answers 0 for any
# directory you can search; its own test asserts that without a mock.
#
# What it deliberately does not do is RUN the program: that would be the first
# probe in this seam to execute a downloaded binary, and it would turn a slow
# or blocked spawn into an install failure (M104).
#
# It answers one unnamed logical per path, elementwise, so the whole produced
# set is asked in one call (M105). `!is.na(info$size)` is back since M138: the
# predicate answers "runnable" for a bare name found on `PATH`, whose
# `file.info()` row is all NA, and `TRUE & NA` would be NA in the caller's
# `unpacked[!...]`.
tm_usable_binary <- function(path) {
info <- file.info(path, extra_cols = FALSE)
tm_runnable_program(path) & !is.na(info$size) & info$size > 0
}
# What the install would do, one item per line: the published digest where
# one will be fetched, the archive to be fetched, the directory it unpacks
# into, and each remembered-location file it may overwrite. `sidecar_url` is
# NULL on the paths that fetch no digest, so the prompt names every fetch the
# call makes and only those (M101; M102 AC1/AC2). The overwrite lines are what
# the call MAY write: which of the three the archive turns out to contain is
# not knowable before it is unpacked, so the prompt names all three and the
# install says out loud which one it skipped (M102 AC4).
# Every caller-supplied value goes through a cli field, which does
# not recurse into the value, so a directory containing braces is shown rather
# than evaluated (M44); the result is stripped of styling and hyperlinks so
# what reaches `menu()` is the plain text a test can read back.
#
# Both the prompt and the non-interactive refusal are built from this, so a
# caller who is told to pass `confirm = FALSE` has been shown the same items
# the prompt would have named.
tm_install_details <- function(download_url, install_dir, programs,
sidecar_url = NULL) {
line <- function(...) cli::ansi_strip(cli::format_inline(..., .envir = parent.frame()))
c(
if (!is.null(sidecar_url)) line("Download: {.url {sidecar_url}}"),
line("Download: {.url {download_url}}"),
line("Unpack into: {.file {install_dir}}"),
vapply(
programs,
function(p) line("Overwrite remembered location: {.file {tm_config_file(p)}}"),
character(1),
USE.NAMES = FALSE
)
)
}
# What a set_program() call would do, one item per line: the location it would
# write, and the file that would record it. That is the location as the caller
# typed it, with a leading `~` expanded (D098), so it is what the prompt names
# -- never a path `Sys.which()` resolved it to, which is not what lands in the
# file.
#
# Every value goes through a cli field, which does not recurse into the value,
# so a location containing braces is shown rather than evaluated (M44); the
# result is stripped of styling and hyperlinks so what reaches `menu()` is the
# plain text a test can read back. Same shape as tm_install_details() above,
# for the same reasons.
#
# Both the prompt and the non-interactive refusal are built from this, so a
# caller who is told to pass `confirm = FALSE` has been shown the same items
# the prompt would have named.
tm_set_details <- function(program, location, dir = tm_config_dir()) {
line <- function(...) cli::ansi_strip(cli::format_inline(..., .envir = parent.frame()))
c(
line("Remember this location: {.file {location}}"),
line("By writing: {.file {tm_config_file(program, dir)}}")
)
}
# The consent prompt: the question, then the items, one per line.
tm_set_prompt <- function(program, location, dir = tm_config_dir()) {
paste(
c(
paste0("tidymedia is about to remember where ", program, " is. Proceed?"),
paste0("* ", tm_set_details(program, location, dir))
),
collapse = "\n"
)
}
# The consent prompt: the question, then the items, one per line.
tm_install_prompt <- function(download_url, install_dir, programs,
sidecar_url = NULL) {
paste(
c(
"tidymedia is about to install FFmpeg. Proceed?",
paste0("* ", tm_install_details(download_url, install_dir, programs,
sidecar_url))
),
collapse = "\n"
)
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.