From e48c9f47ebb69701ce0ecf0d2f49e13a4c469568 Mon Sep 17 00:00:00 2001 From: Guido Cella Date: Tue, 7 Jan 2025 13:50:46 +0100 Subject: [PATCH 1/3] misc: extract find_non_filename_media_title() from command.c This will be used in the next commit. --- player/command.c | 21 --------------------- player/core.h | 1 + player/misc.c | 21 +++++++++++++++++++++ 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/player/command.c b/player/command.c index cc9c84e2bcef3..1fc0350e6d683 100644 --- a/player/command.c +++ b/player/command.c @@ -577,27 +577,6 @@ static int mp_property_file_size(void *ctx, struct m_property *prop, return m_property_int64_ro(action, arg, size); } -static const char *find_non_filename_media_title(MPContext *mpctx) -{ - const char *name = mpctx->opts->media_title; - if (name && name[0]) - return name; - if (mpctx->demuxer) { - name = mp_tags_get_str(mpctx->demuxer->metadata, "service_name"); - if (name && name[0]) - return name; - name = mp_tags_get_str(mpctx->demuxer->metadata, "title"); - if (name && name[0]) - return name; - name = mp_tags_get_str(mpctx->demuxer->metadata, "icy-title"); - if (name && name[0]) - return name; - } - if (mpctx->playing && mpctx->playing->title) - return mpctx->playing->title; - return NULL; -} - static int mp_property_media_title(void *ctx, struct m_property *prop, int action, void *arg) { diff --git a/player/core.h b/player/core.h index 9ce0f1daca930..b03cc65222da4 100644 --- a/player/core.h +++ b/player/core.h @@ -576,6 +576,7 @@ int stream_dump(struct MPContext *mpctx, const char *source_filename); double get_track_seek_offset(struct MPContext *mpctx, struct track *track); bool str_in_list(bstr str, char **list); char *mp_format_track_metadata(void *ctx, struct track *t, bool add_lang); +const char *find_non_filename_media_title(MPContext *mpctx); // osd.c void set_osd_bar(struct MPContext *mpctx, int type, diff --git a/player/misc.c b/player/misc.c index 08875a634b722..391ab0551a401 100644 --- a/player/misc.c +++ b/player/misc.c @@ -413,3 +413,24 @@ char *mp_format_track_metadata(void *ctx, struct track *t, bool add_lang) return bstrto0(ctx, dst); } + +const char *find_non_filename_media_title(MPContext *mpctx) +{ + const char *name = mpctx->opts->media_title; + if (name && name[0]) + return name; + if (mpctx->demuxer) { + name = mp_tags_get_str(mpctx->demuxer->metadata, "service_name"); + if (name && name[0]) + return name; + name = mp_tags_get_str(mpctx->demuxer->metadata, "title"); + if (name && name[0]) + return name; + name = mp_tags_get_str(mpctx->demuxer->metadata, "icy-title"); + if (name && name[0]) + return name; + } + if (mpctx->playing && mpctx->playing->title) + return mpctx->playing->title; + return NULL; +} From 30edf1a8cecad7a0f1636457512fff0e4f5d4cf9 Mon Sep 17 00:00:00 2001 From: Guido Cella Date: Tue, 7 Jan 2025 13:52:07 +0100 Subject: [PATCH 2/3] loadfile: optionally save the watch history The history could be formatted as CSV, but this requires escaping the separator in the fields and doesn't work with paths and titles with newlines, or as JSON, but it is inefficient to reread and rewrite the whole history on each new file, and doing so overwrites the history with an empty file when writing without disk space left. So this uses a hybrid of one JSON object per line to get the best of both worlds. This is called NDJSON or JSONL. --- DOCS/interface-changes/watch-history.txt | 1 + DOCS/man/options.rst | 19 ++++++++ options/options.c | 4 ++ options/options.h | 2 + player/loadfile.c | 62 ++++++++++++++++++++++++ 5 files changed, 88 insertions(+) create mode 100644 DOCS/interface-changes/watch-history.txt diff --git a/DOCS/interface-changes/watch-history.txt b/DOCS/interface-changes/watch-history.txt new file mode 100644 index 0000000000000..28e5c674f816b --- /dev/null +++ b/DOCS/interface-changes/watch-history.txt @@ -0,0 +1 @@ +add `--save-watch-history` and `--watch-history-path` options diff --git a/DOCS/man/options.rst b/DOCS/man/options.rst index 4e932f1eff69c..1a172495d50ab 100644 --- a/DOCS/man/options.rst +++ b/DOCS/man/options.rst @@ -1148,6 +1148,25 @@ Watch Later Ignore path (i.e. use filename only) when using watch later feature. (Default: disabled) +Watch History +------------- + +``--save-watch-history`` + Whether to save which files are played. + + .. warning:: + + This option may expose privacy-sensitive information and is thus + disabled by default. + +``--watch-history-path=`` + The path in which to store the watch history. Default: + ``~~state/watch_history.jsonl`` (see `PATHS`_). + + This file contains one JSON object per line. Its ``time`` field is the UNIX + timestamp when the file was opened, its ``path`` field is the normalized + path, and its ``title`` field is the title when it was available. + Video ----- diff --git a/options/options.c b/options/options.c index 3bb958c49a042..2d7cfce922a10 100644 --- a/options/options.c +++ b/options/options.c @@ -811,6 +811,9 @@ static const m_option_t mp_opts[] = { {"watch-later-directory", OPT_ALIAS("watch-later-dir")}, {"watch-later-options", OPT_STRINGLIST(watch_later_options)}, + {"save-watch-history", OPT_BOOL(save_watch_history)}, + {"watch-history-path", OPT_STRING(watch_history_path), .flags = M_OPT_FILE}, + {"ordered-chapters", OPT_BOOL(ordered_chapters)}, {"ordered-chapters-files", OPT_STRING(ordered_chapters_files), .flags = M_OPT_FILE}, @@ -986,6 +989,7 @@ static const struct MPOpts mp_default_opts = { .sync_max_factor = 5, .load_config = true, .position_resume = true, + .watch_history_path = "~~state/watch_history.jsonl", .autoload_files = true, .demuxer_thread = true, .demux_termination_timeout = 0.1, diff --git a/options/options.h b/options/options.h index 3f184c49cf5a9..ef8c4306845db 100644 --- a/options/options.h +++ b/options/options.h @@ -276,6 +276,8 @@ typedef struct MPOpts { bool ignore_path_in_watch_later_config; char *watch_later_dir; char **watch_later_options; + bool save_watch_history; + char *watch_history_path; bool pause; int keep_open; bool keep_open_pause; diff --git a/player/loadfile.c b/player/loadfile.c index 4f204c20e75fa..d138414d9ac5c 100644 --- a/player/loadfile.c +++ b/player/loadfile.c @@ -19,6 +19,7 @@ #include #include #include +#include #include @@ -44,6 +45,7 @@ #include "common/encode.h" #include "common/stats.h" #include "input/input.h" +#include "misc/json.h" #include "misc/language.h" #include "audio/out/ao.h" @@ -1521,6 +1523,64 @@ static void load_external_opts(struct MPContext *mpctx) mp_waiter_wait(&wait); } +static void append_to_watch_history(struct MPContext *mpctx) +{ + if (!mpctx->opts->save_watch_history) + return; + + void *ctx = talloc_new(NULL); + char *history_path = mp_get_user_path(ctx, mpctx->global, + mpctx->opts->watch_history_path); + FILE *history_file = fopen(history_path, "ab"); + + if (!history_file) { + MP_ERR(mpctx, "Failed to write to %s: %s\n", history_path, + mp_strerror(errno)); + goto done; + } + + char *title = (char *)find_non_filename_media_title(mpctx); + + mpv_node_list *list = talloc_zero(ctx, mpv_node_list); + mpv_node node = { + .format = MPV_FORMAT_NODE_MAP, + .u.list = list, + }; + list->num = title ? 3 : 2; + list->keys = talloc_array(ctx, char*, list->num); + list->values = talloc_array(ctx, mpv_node, list->num); + list->keys[0] = "time"; + list->values[0] = (struct mpv_node) { + .format = MPV_FORMAT_INT64, + .u.int64 = time(NULL), + }; + list->keys[1] = "path"; + list->values[1] = (struct mpv_node) { + .format = MPV_FORMAT_STRING, + .u.string = mp_normalize_path(ctx, mpctx->filename), + }; + if (title) { + list->keys[2] = "title"; + list->values[2] = (struct mpv_node) { + .format = MPV_FORMAT_STRING, + .u.string = title, + }; + } + + char *dst = talloc_strdup(ctx, ""); + json_write(&dst, &node); + dst = talloc_strdup_append(dst, "\n"); + + bool failed = fwrite(dst, strlen(dst), 1, history_file) != 1; + if (fclose(history_file) || failed) { + MP_ERR(mpctx, "Failed to write to %s: %s\n", history_path, + mp_strerror(errno)); + } + +done: + talloc_free(ctx); +} + // Start playing the current playlist entry. // Handle initialization and deinitialization. static void play_current_file(struct MPContext *mpctx) @@ -1772,6 +1832,8 @@ static void play_current_file(struct MPContext *mpctx) if (watch_later) mp_delete_watch_later_conf(mpctx, mpctx->filename); + append_to_watch_history(mpctx); + if (mpctx->max_frames == 0) { if (!mpctx->stop_play) mpctx->stop_play = PT_NEXT_ENTRY; From 35737cbef4f464429552f448a6cef06f26aca9b2 Mon Sep 17 00:00:00 2001 From: Guido Cella Date: Fri, 10 Jan 2025 10:36:22 +0100 Subject: [PATCH 3/3] select.lua: select from the watch history with g-h Implement selection of the entries in the watch history. The last entry in the selector deletes the history file. --- DOCS/man/mpv.rst | 3 ++ DOCS/man/options.rst | 3 +- etc/input.conf | 1 + player/lua/select.lua | 78 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/DOCS/man/mpv.rst b/DOCS/man/mpv.rst index 7c67cf0d96d18..ecb54f42abec9 100644 --- a/DOCS/man/mpv.rst +++ b/DOCS/man/mpv.rst @@ -328,6 +328,9 @@ g-l g-d Select an audio device. +g-h + Select a file from the watch history. Requires ``--save-watch-history``. + g-w Select a file from watch later config files (see `RESUMING PLAYBACK`_) to resume playing. Requires ``--write-filename-in-watch-later-config``. This diff --git a/DOCS/man/options.rst b/DOCS/man/options.rst index 1a172495d50ab..79f4376df559c 100644 --- a/DOCS/man/options.rst +++ b/DOCS/man/options.rst @@ -1152,7 +1152,8 @@ Watch History ------------- ``--save-watch-history`` - Whether to save which files are played. + Whether to save which files are played. These can be then selected with the + default ``g-h`` key binding. .. warning:: diff --git a/etc/input.conf b/etc/input.conf index 2faa88c233b28..adbb824a0d838 100644 --- a/etc/input.conf +++ b/etc/input.conf @@ -187,6 +187,7 @@ #g-e script-binding select/select-edition #g-l script-binding select/select-subtitle-line #g-d script-binding select/select-audio-device +#g-h script-binding select/select-watch-history #g-w script-binding select/select-watch-later #g-b script-binding select/select-binding #g-r script-binding select/show-properties diff --git a/player/lua/select.lua b/player/lua/select.lua index 7f20602aff098..81479e0c1cdc7 100644 --- a/player/lua/select.lua +++ b/player/lua/select.lua @@ -18,6 +18,13 @@ License along with mpv. If not, see . local utils = require "mp.utils" local input = require "mp.input" +local options = { + history_date_format = "%Y-%m-%d %H:%M:%S", + hide_history_duplicates = true, +} + +require "mp.options".read_options(options, nil, function () end) + local function show_warning(message) mp.msg.warn(message) if mp.get_property_native("vo-configured") then @@ -353,6 +360,77 @@ mp.add_key_binding(nil, "select-audio-device", function () }) end) +local function format_history_entry(entry) + local status + status, entry.time = pcall(os.date, options.history_date_format, entry.time) + + if not status then + mp.msg.warn(entry.time) + end + + return "(" .. entry.time .. ") " .. + (entry.title or select(2, utils.split_path(entry.path))) .. + " (" .. entry.path .. ")" +end + +mp.add_key_binding(nil, "select-watch-history", function () + local history_file_path = mp.command_native( + {"expand-path", mp.get_property("watch-history-path")}) + local history_file, error_message = io.open(history_file_path) + if not history_file then + show_warning(mp.get_property_native("save-watch-history") + and error_message + or "Enable --save-watch-history") + return + end + + local all_entries = {} + local line_num = 1 + for line in history_file:lines() do + local entry = utils.parse_json(line) + if entry and entry.path then + all_entries[#all_entries + 1] = entry + else + mp.msg.warn(history_file_path .. ": Parse error at line " .. line_num) + end + line_num = line_num + 1 + end + history_file:close() + + local entries = {} + local items = {} + local seen = {} + + for i = #all_entries, 1, -1 do + local entry = all_entries[i] + if not seen[entry.path] or not options.hide_history_duplicates then + seen[entry.path] = true + entries[#entries + 1] = entry + items[#items + 1] = format_history_entry(entry) + end + end + + items[#items+1] = "Clear history" + + input.select({ + prompt = "Select a file:", + items = items, + submit = function (i) + if entries[i] then + mp.commandv("loadfile", entries[i].path) + return + end + + error_message = select(2, os.remove(history_file_path)) + if error_message then + show_error(error_message) + else + mp.osd_message("History cleared.") + end + end, + }) +end) + mp.add_key_binding(nil, "select-watch-later", function () local watch_later_dir = mp.get_property("current-watch-later-dir")