Cutting Online TV Recorder videos on Linux with mpv, Celluloid and otr
On Linux, there are only a few tools for decoding and cutting videos that were recorded with Online TV Recorder. otr is one of them. If no cut list is available for a video, otr has to be called manually with cut intervals that were determined beforehand. This post describes how a small Lua script makes it possible to mark the cut intervals while watching the video in mpv or Celluloid, and to cut the video with otr at the press of a key.
Online TV Recorder and otr
Online TV Recorder (OTR) is a service that records TV programs of German, Austrian and Swiss stations. Recordings can be downloaded as encoded OTRKEY or OTR2 files, which have to be decoded before they can be watched. Since TV recordings come with commercials and some minutes before and after the actual program, they are typically cut as well. The cut intervals are taken from cut lists that are shared by the community on cutlist.at.
OTR itself provides tools for decoding and cutting mainly for Windows. That's why I wrote otr, a command line tool for Linux and macOS that decodes OTRKEY and OTR2 files and cuts the videos frame-accurately with FFmpeg and FFMS2. It downloads suitable cut lists from cutlist.at automatically, so otr process typically does everything at once. Packages are available for Arch Linux, Debian, Ubuntu, Linux Mint, Fedora and openSUSE in the nerdstuff package repository.
No cut list? Determine the intervals yourself
Sometimes, there is no cut list on cutlist.at for a video (yet). In this case, otr accepts the cut intervals on the command line:
$ otr cut --cutlist "times:[0:05:30,0:20:59.45][0:27:10,0:48:02.2]" <VIDEO FILE>
Each interval in brackets is a part of the video that is kept. otr can even upload such a self-made cut list to cutlist.at, so that others can use it as well.
The intervals themselves have to be determined with a video editor such as Avidemux: jump to the beginning and the end of each part, write down the timestamps, and type them into the command above. That works, but it is tedious and error-prone. What I wanted instead was a graphical tool in which I can mark the intervals while watching the video, and which passes them to otr automatically. I.e., a player or editor with a plugin API.
mpv as cutting tool
It turned out that I already had such a tool: mpv. mpv can be extended with Lua scripts, which can define key bindings, read properties such as the current playback position, show messages in the on-screen display (OSD), and start external programs. That's all that is needed.
The script otr-cut.lua below provides these keys:
| Key | Function |
|---|---|
i |
set the in point (beginning of a part to keep) |
o |
set the out point (end of that part) - this completes the interval |
u |
remove the last interval |
l |
show the list of intervals |
W |
run otr with the cut list |
. / , |
move one frame forward / backward (mpv default bindings) |
The intervals are sorted, so they can be marked in any order. Pressing W builds a cut list string in the times:[...] format from them and runs otr cut --cutlist <CUT LIST> <FILE> in a terminal window, which stays open after otr has finished, so that its output can be read. The terminal is configured at the beginning of the script. I use GNOME Console (kgx), which only accepts the command as one string, hence TERMINAL_ONE_STRING. Alternatives for other terminals are listed in the comments. With TERMINAL = nil, otr runs invisibly in the background. The intervals are reset whenever a new file is loaded.
-- otr-cut.lua - mark cut intervals in mpv and pass them to otr
--
-- SPDX-FileCopyrightText: 2026 Michael Picht <mipi@fsfe.org>
--
-- SPDX-License-Identifier: GPL-3.0-or-later
--
-- Installation: copy to ~/.config/mpv/scripts/
--
-- Keys:
-- i set in point
-- o set out point (completes the interval)
-- u remove last interval
-- l show interval list
-- W run otr with the cut list
--
-- Frame-accurate positioning: "." / "," (forward/backward, pauses playback)
local mp = require 'mp'
-- otr invocation; "{cutlist}" and "{file}" are substituted
local OTR_CMD = { "otr", "cut", "--cutlist", "{cutlist}", "{file}" }
-- Terminal in which otr is run (nil = invisible, in the background).
-- otr runs inside a bash that waits for Enter afterwards, so that the
-- output stays visible.
--
-- GNOME Console (kgx) has no --hold option and accepts only ONE string
-- with -e; TERMINAL_ONE_STRING = true builds a properly quoted command
-- line from the arguments.
local TERMINAL = { "kgx", "-e" }
local TERMINAL_ONE_STRING = true
-- Alternatives (set TERMINAL_ONE_STRING = false):
-- { "gnome-terminal", "--" }
-- { "alacritty", "-e" }
-- { "foot" }
local HOLD_SCRIPT = '"$@"; echo; read -rp "Press Enter to close "'
-- Quote a string for POSIX shells
local function shquote(s)
return "'" .. s:gsub("'", "'\\''") .. "'"
end
local segs = {}
local cur_in = nil
-- [H]H:MM:SS.ssssss (LuaJIT/Lua 5.1 compatible, hence no //)
local function ts(t)
local h = math.floor(t / 3600)
local m = math.floor((t % 3600) / 60)
local s = t - h * 3600 - m * 60
return string.format("%d:%02d:%09.6f", h, m, s)
end
local function cutlist_string()
local parts = {}
for _, s in ipairs(segs) do
table.insert(parts, "[" .. ts(s[1]) .. "," .. ts(s[2]) .. "]")
end
return "times:" .. table.concat(parts)
end
local function show_list(duration)
if #segs == 0 then
mp.osd_message("No intervals", duration or 2)
return
end
local lines = {}
for n, s in ipairs(segs) do
table.insert(lines, string.format("%d: %s – %s", n, ts(s[1]), ts(s[2])))
end
mp.osd_message(table.concat(lines, "\n"), duration or 5)
end
mp.add_key_binding("i", "otr-in", function()
cur_in = mp.get_property_number("time-pos")
if cur_in then mp.osd_message("IN " .. ts(cur_in)) end
end)
mp.add_key_binding("o", "otr-out", function()
local out = mp.get_property_number("time-pos")
if not cur_in or not out then
mp.osd_message("Set in point first (i)")
return
end
if out <= cur_in then
mp.osd_message("Out point is not after in point")
return
end
table.insert(segs, { cur_in, out })
table.sort(segs, function(a, b) return a[1] < b[1] end)
cur_in = nil
show_list(3)
end)
mp.add_key_binding("u", "otr-undo", function()
table.remove(segs)
show_list(3)
end)
mp.add_key_binding("l", "otr-list", function() show_list(5) end)
mp.add_key_binding("W", "otr-cut", function()
if #segs == 0 then
mp.osd_message("No intervals")
return
end
local spec = cutlist_string()
local file = mp.get_property("path")
local otr_args = {}
for _, a in ipairs(OTR_CMD) do
a = a:gsub("{cutlist}", function() return spec end)
a = a:gsub("{file}", function() return file end)
table.insert(otr_args, a)
end
local args = {}
if TERMINAL then
-- wrap otr in a bash that keeps the terminal open; otr args end up in "$@"
local inner = { "bash", "-c", HOLD_SCRIPT, "otr-cut" }
for _, a in ipairs(otr_args) do table.insert(inner, a) end
for _, a in ipairs(TERMINAL) do table.insert(args, a) end
if TERMINAL_ONE_STRING then
local q = {}
for _, a in ipairs(inner) do table.insert(q, shquote(a)) end
table.insert(args, table.concat(q, " "))
else
for _, a in ipairs(inner) do table.insert(args, a) end
end
else
args = otr_args
end
mp.msg.info("otr: " .. table.concat(args, " "))
mp.osd_message("otr is running …", 2)
-- asynchronous, so that mpv does not block
mp.command_native_async({
name = "subprocess", args = args,
playback_only = false, capture_stderr = true,
}, function(success, r)
if success and r.status == 0 then
mp.osd_message(TERMINAL and "otr started in terminal" or "otr: done", 3)
else
local msg = (r and r.stderr ~= "" and r.stderr)
or (r and r.error_string)
or "?"
if r and r.status and r.status < 0 then
msg = "could not be started (" .. args[1] .. " not in PATH?) " .. msg
end
mp.msg.error("otr: " .. msg)
mp.osd_message("otr error: " .. msg, 8)
end
end)
end)
-- Reset intervals when a new file is loaded
mp.register_event("file-loaded", function()
segs, cur_in = {}, nil
end)
The timestamps are taken from mpv's time-pos property. Since otr cuts frame-accurately, it pays off to position exactly at the first and last frame of a part with . and , before pressing i or o.
Installation
For mpv, the script is copied to the scripts directory, from where mpv loads it automatically at start:
$ cp otr-cut.lua ~/.config/mpv/scripts/
The same script works in Celluloid, the GTK frontend for mpv, without any change. Celluloid does not read ~/.config/mpv/scripts/, though, but has a plugin directory of its own:
$ cp otr-cut.lua ~/.config/celluloid/scripts/
If Celluloid is installed as Flatpak, that directory is located inside the sandbox:
~/.var/app/io.github.celluloid_player.Celluloid/config/celluloid/scripts/
Of course, otr must be installed and in the PATH. And since otr moves cut videos to its working directory, it makes sense to open the decoded video from there (i.e., from <OTR WORKING DIR>/Decoded).
Why the same script works in mpv and Celluloid
Celluloid is built on libmpv, and libmpv is the core of mpv as a library. The Lua engine together with the mp API is part of that core. Strictly speaking, the mpv program does not use libmpv, but it is built from the same code. Everything the script uses runs in the core: properties such as time-pos, the OSD, the subprocess command, and key bindings. That's why the script behaves the same in both players.
Two things depend on the frontend, however:
- Loading scripts: the frontend decides which scripts are loaded (via mpv options). That's why Celluloid uses its own plugin directory.
- Key input: in Celluloid, GTK receives the key presses and forwards them to libmpv. Keys that Celluloid handles itself never reach the bindings of the script. That's where key conflicts can come from.
Custom key bindings
The keys can be changed in two ways.
Via input.conf
Entries in an input.conf refer to the names of the script bindings (otr-cut/<NAME>, where otr-cut is the script name) and take precedence over the default keys the script sets with mp.add_key_binding. For example, to use l for the left boundary, r for the right one, and C to start cutting:
l script-binding otr-cut/otr-in
r script-binding otr-cut/otr-out
C script-binding otr-cut/otr-cut
L script-binding otr-cut/otr-list
Since l is now the left boundary, the list of intervals moves to L. u and the default keys i, o and W keep working, as long as they are not bound otherwise. For mpv, the file is ~/.config/mpv/input.conf. Celluloid can be told to load an input.conf of its own in its preferences, which makes it possible to configure the two players differently.
Directly in the script
Without an input.conf, the keys can be changed in the script itself. The key is the first argument of mp.add_key_binding:
mp.add_key_binding("l", "otr-in", ...)
mp.add_key_binding("r", "otr-out", ...)
mp.add_key_binding("L", "otr-list", ...)
mp.add_key_binding("C", "otr-cut", ...)
This works as long as Celluloid does not use these keys itself. For keys that Celluloid occupies, either an input.conf is needed (since its entries take precedence), or the script uses mp.add_forced_key_binding instead, which even overrides input.conf. The frame step keys can be bound in the script as well, should they not work in Celluloid out of the box:
mp.add_key_binding(".", "otr-frame-fwd", function() mp.command("frame-step") end)
mp.add_key_binding(",", "otr-frame-back", function() mp.command("frame-back-step") end)
The downside is that the bindings are then the same for mpv and Celluloid.
Conclusion
With about 150 lines of Lua, mpv and Celluloid become a simple but sufficient cutting frontend for otr: watch the video, press i and o at the boundaries of the parts to keep, press W, done. No more copying timestamps from Avidemux into the terminal. And if otr is configured to submit cut lists, the next person who records the same program does not even need the script.
If you give the script a try, feedback is very welcome.