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/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 4e932f1eff69c..79f4376df559c 100644 --- a/DOCS/man/options.rst +++ b/DOCS/man/options.rst @@ -1148,6 +1148,26 @@ 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. These can be then selected with the + default ``g-h`` key binding. + + .. 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/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/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/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/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; 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") 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; +}