diff --git a/.config/mpv/filters/FrameInterpolation.vpy b/.config/mpv/filters/FrameInterpolation.vpy new file mode 100644 index 0000000..6818cde --- /dev/null +++ b/.config/mpv/filters/FrameInterpolation.vpy @@ -0,0 +1,28 @@ +# vim: set ft=python: + +import vapoursynth as vs +from vapoursynth import core +clip = video_in + +#You can change the desired framerate here +dst_fps = display_fps + +#BlockSize can be changed to 16 for better performance +BlockSize=8 + +src_fps_num = int(container_fps * 1e8) +src_fps_den = int(1e8) +dst_fps_num = int(dst_fps * 1e4) +dst_fps_den = int(1e4) + +# Needed because clip FPS is missing +clip = core.std.AssumeFPS(clip, fpsnum = src_fps_num, fpsden = src_fps_den) +print("Reflowing from ",src_fps_num/src_fps_den," fps to ",dst_fps_num/dst_fps_den," fps.") + +#Pel can be changed to 4 for better accuracy or 1 for better speed +sup = core.mv.Super(clip, pel=2, hpad=BlockSize, vpad=BlockSize) +bvec = core.mv.Analyse(sup, blksize=BlockSize, isb=True , chroma=True, search=3, searchparam=1) +fvec = core.mv.Analyse(sup, blksize=BlockSize, isb=False, chroma=True, search=3, searchparam=1) +clip = core.mv.BlockFPS(clip, sup, bvec, fvec, num=dst_fps_num, den=dst_fps_den, mode=3, thscd2=12) + +clip.set_output() diff --git a/.config/mpv/filters/motioninterpolation.vpy b/.config/mpv/filters/motioninterpolation.vpy new file mode 100644 index 0000000..5818912 --- /dev/null +++ b/.config/mpv/filters/motioninterpolation.vpy @@ -0,0 +1,78 @@ +# vim: set ft=python: + +# see the README at https://gist.github.com/phiresky/4bfcfbbd05b3c2ed8645 +# source: https://github.com/mpv-player/mpv/issues/2149 +# source: https://github.com/mpv-player/mpv/issues/566 +# source: https://github.com/haasn/gentoo-conf/blob/nanodesu/home/nand/.mpv/filters/mvtools.vpy + +import vapoursynth + +core = vapoursynth.core +# ref: http://avisynth.org.ru/mvtools/mvtools2.html#functions +# default is 400, less means interpolation will only happen when it will work well +ignore_threshold = 400 +# if n% of blocks change more than threshold then don't interpolate at all (default is 51%) +scene_change_percentage = 51 + +dst_fps = 144 +# Interpolating to fps higher than 60 is too CPU-expensive, smoothmotion can handle the rest. +while dst_fps > 60: + dst_fps /= 2 + +if "video_in" in globals(): + # realtime + clip = video_in + # Needed because clip FPS is missing + src_fps_num = int(container_fps * 1e8) + src_fps_den = int(1e8) + clip = core.std.AssumeFPS(clip, fpsnum=src_fps_num, fpsden=src_fps_den) +else: + # run with vspipe + clip = core.ffms2.Source(source=in_filename) + dst_fps = float(dst_fps) + +# resolution in megapixels. 1080p ≈ 2MP, 720p ≈ 1MP +mpix = clip.width * clip.height / 1000000 + +# Skip interpolation for >1080p or 60 Hz content due to performance +if not (mpix > 2.5 or clip.fps_num / clip.fps_den > 59): + analParams = { + "overlap": 0, + "search": 3, + "truemotion": True, + #'chrome': True, + #'blksize':16, + #'searchparam':1 + } + blockParams = { + "thscd1": ignore_threshold, + "thscd2": int(scene_change_percentage * 255 / 100), + "mode": 3, + } + + # default 1.5 or so + if mpix > 4.5: + # can't handle these on Full HD with Intel i5-2500k + # see the description of these parameters in http://avisynth.org.ru/mvtools/mvtools2.html#functions + analParams["search"] = 0 + blockParams["mode"] = 0 + quality = "low" + else: + quality = "high" + + dst_fps_num = int(dst_fps * 1e4) + dst_fps_den = int(1e4) + print( + "Reflowing from {} fps to {} fps (quality={})".format( + clip.fps_num / clip.fps_den, dst_fps_num / dst_fps_den, quality + ) + ) + + sup = core.mv.Super(clip, pel=2) + bvec = core.mv.Analyse(sup, isb=True, **analParams) + fvec = core.mv.Analyse(sup, isb=False, **analParams) + clip = core.mv.BlockFPS( + clip, sup, bvec, fvec, num=dst_fps_num, den=dst_fps_den, **blockParams + ) + +clip.set_output() diff --git a/.config/mpv/filters/mvtools.vpy b/.config/mpv/filters/mvtools.vpy new file mode 100644 index 0000000..8cd8245 --- /dev/null +++ b/.config/mpv/filters/mvtools.vpy @@ -0,0 +1,45 @@ +# vim: set ft=python: + +import vapoursynth as vs + +core = vs.core +core.std.LoadPlugin( + path="/usr/lib/python3.14/site-packages/vapoursynth/plugins/mvtools.so" +) +clip = video_in + +dst_fps = display_fps +# Interpolating to fps higher than 60 is too CPU-expensive, smoothmotion can handle the rest. +while dst_fps > 60: + dst_fps = 60 + +dst_fps = 144 + +# Skip interpolation for > 4K or 60 Hz content due to performance +if not (clip.width > 3840 or clip.height > 2160 or container_fps > 59): + src_fps_num = int(container_fps * 1e8) + src_fps_den = int(1e8) + dst_fps_num = int(dst_fps * 1e4) + dst_fps_den = int(1e4) + # Needed because clip FPS is missing + clip = core.std.AssumeFPS(clip, fpsnum=src_fps_num, fpsden=src_fps_den) + print( + "Reflowing from ", + src_fps_num / src_fps_den, + " fps to ", + dst_fps_num / dst_fps_den, + " fps.", + ) + + sup = core.mv.Super(clip, pel=2, hpad=16, vpad=16) + bvec = core.mv.Analyse( + sup, blksize=16, isb=True, chroma=True, search=3, searchparam=1 + ) + fvec = core.mv.Analyse( + sup, blksize=16, isb=False, chroma=True, search=3, searchparam=1 + ) + clip = core.mv.BlockFPS( + clip, sup, bvec, fvec, num=dst_fps_num, den=dst_fps_den, mode=3, thscd2=12 + ) + +clip.set_output() diff --git a/.config/mpv/input.conf b/.config/mpv/input.conf new file mode 100644 index 0000000..108160f --- /dev/null +++ b/.config/mpv/input.conf @@ -0,0 +1,230 @@ +# mpv auto fullscreen fix: +# windowrulev2 = suppressevent fullscreen,class:^(mpv.*)$ +# needs this in mpv's input.conf: +# f run "/bin/sh" "-c" "hyprctl dispatch fullscreen toggle"; cycle fullscreen + + +ü vf toggle format=yuv420p,vapoursynth="~/.config/mpv/filters/mvtools.vpy":4:4 + +### cool profiles function: +# _ set profile FrameInterpolation #menu: Profiles > profile FrameInterpolation + + +### SEEKING ### +# Relative Seeking +#Shift+RIGHT seek 1 exact +#Shift+LEFT seek -1 exact +#RIGHT seek 5 exact ; script-binding uosc/flash-timeline +#LEFT seek -5 exact ; script-binding uosc/flash-timeline +#Shift+DOWN seek 5 ; script-binding uosc/flash-timeline +#Shift+UP seek -5 ; script-binding uosc/flash-timeline +#DOWN seek 60 ; script-binding uosc/flash-timeline +#UP seek -60 ; script-binding uosc/flash-timeline +#Shift+PGDWN seek 600 ; script-binding uosc/flash-timeline +#Shift+PGUP seek -600 ; script-binding uosc/flash-timeline + +# Absolute seeking +HOME osd-msg seek 0 absolute; set pause no +END osd-msg seek 100 absolute-percent + +Ctrl+1 osd-msg seek 10 absolute-percent;set pause no +Ctrl+2 osd-msg seek 20 absolute-percent;set pause no +Ctrl+3 osd-msg seek 30 absolute-percent;set pause no +Ctrl+4 osd-msg seek 40 absolute-percent;set pause no +Ctrl+5 osd-msg seek 50 absolute-percent;set pause no +Ctrl+6 osd-msg seek 60 absolute-percent;set pause no +Ctrl+7 osd-msg seek 70 absolute-percent;set pause no +Ctrl+8 osd-msg seek 80 absolute-percent;set pause no +Ctrl+9 osd-msg seek 90 absolute-percent;set pause no +Ctrl+0 osd-msg seek 0 absolute; set pause no + + +# generate thumbnails using the mpv_thumbnail_script +# https://github.com/TheAMM/mpv_thumbnail_script#configuration +alt+s script-binding generate-thumbnails + +del script-message osc-visibility cycle + +#DEL script-message cycle-cmd "script-message osc-visibility always" "set osd-level 1; script-message osc-visibility never" + + +# File and chapter seeking +#PGUP add chapter -1 +#PGDWN add chapter 1 +#Ctrl+PGUP playlist-prev +#Ctrl+PGDWN playlist-next +#Ctrl+z osd-msg-bar revert-seek + +# Skip to previous/next subtitle (subject to some restrictions; see manpage) +#Alt+LEFT no-osd sub-seek -1 +#Alt+RIGHT no-osd sub-seek 1 + +# Seek with ctrl+mouse wheel +#Ctrl+AXIS_UP osd-msg-bar seek 2 +#Ctrl+AXIS_DOWN osd-msg-bar seek -2 + + +### UNSET BINDINGS TO BE REBOUND ### +#r ignore +#R ignore +s ignore +#e ignore +g ignore +#w ignore +#z ignore +#x ignore +#r ignore +#t ignore +#T ignore +#v ignore +#V ignore +#_ ignore +#SHARP ignore + + +### TOGGLE/CYCLE STUFF ### +#z-c cycle-values audio-channels auto-safe mono +#z-d cycle deband +#z-D cycle deinterlace +#z-y vf toggle yadif +#z-i cycle interpolation +#z-s cycle sub-visibility +#z-a cycle mute +#z-v cycle sub-ass-vsfilter-aspect-compat +#z-T cycle ontop +#z-r cycle-values video-aspect "16:9" "4:3" "2.35:1" "-1" +#z-t cycle-values tscale "box" "oversample" "linear" "catmull_rom" "mitchell" "gaussian" "bicubic" +#z-o cycle-values ass-style-override "yes" "force" "no" +#z-w cycle force-window +#z-f cycle osd-fractions +#z-b cycle-values blend-subtitles "yes" "no" "video" + +# GLSL Shaders +#g-g change-list glsl-shaders clr "" +#g-r change-list glsl-shaders toggle "/usr/share/mpv-prescalers/vulkan/ravu-r3.hook" +#g-R change-list glsl-shaders toggle "/usr/share/mpv-prescalers/compute/ravu-r3.hook" +#g-l change-list glsl-shaders toggle "~~/shaders/noise_static_luma.hook" +#g-k change-list glsl-shaders toggle "~~/shaders/KrigBilateral.glsl" +#g-f change-list glsl-shaders toggle "~~/shaders/kyoani_fog.glsl" + +# tracks/editions +# see uosc bindings at the end +#e cycle edition +#E cycle edition down + + +### MISC ### + +# Volume control (with uosc calls) +#m cycle mute ; script-binding uosc/flash-volume +#WHEEL_UP add volume 5 ; script-binding uosc/flash-volume +#WHEEL_DOWN add volume -5 ; script-binding uosc/flash-volume +#9 add volume -5 ; script-binding uosc/flash-volume +#0 add volume 5 ; script-binding uosc/flash-volume + +# Absolute window resizing +Alt+1 set window-scale 0.5 +Alt+2 set window-scale 1.0 +Alt+3 set window-scale 1.5 +Alt+4 set window-scale 2.0 +#Alt+5 autofit something + +# Relative window resizing +#Alt+- multiply window-scale 1/1.25 +#Alt++ multiply window-scale 1.25 + +# Playback speed (with uosc calls) +[ multiply speed 1/1.1 ; script-binding uosc/flash-speed +] multiply speed 1.1 ; script-binding uosc/flash-speed +{ multiply speed 0.5 ; script-binding uosc/flash-speed +} multiply speed 2.0 ; script-binding uosc/flash-speed +BS set speed 1 ; script-binding uosc/flash-speed + +s multiply speed 1/1.1 ; script-binding uosc/flash-speed +d multiply speed 1.1 ; script-binding uosc/flash-speed +g set speed 1.6 ; script-binding uosc/flash-speed +G set speed 1 ; script-binding uosc/flash-speed + +# Subtitle adjustments +#h add sub-delay -0.041708333333 # shift subs (by 1 frame, assuming 24fps) +#l add sub-delay 0.041708333333 +#j add sub-pos 1 +#k add sub-pos -1 + +# Other +#Ctrl+l cycle-values loop "inf" "no" +#Ctrl+s screenshot subtitles +#S screenshot video # rebind; s is used for subtitle selection +#P print-text "$filename $time-pos $estimated-frame-number" +#F10 show_text ${chapter-list} +#c show_text ${chapter-list} + +# script-message osc-chapterlist +F10 script-message osc-chapterlist 2 +c script-message osc-chapterlist 2 + + +# Do stuff with ${path} +#F run firefox "${path}"; show-text "Opening in FireFox" +#C run zsh -c "echo -n '${path}' | xclip -sel c"; show-text "Copied path to clipboard" + +# Copy stuff to clipboard +y-y run zsh -c "echo -n '${path}'| xclip -sel c"; show-text "Copied path to clipboard" +y-p run zsh -c "echo -n '${path}'| xclip -sel c"; show-text "Copied path to clipboard" +y-t run zsh -c "echo -n '${time-pos}' | xclip -sel c"; show-text "Copied timestamp to clipboard" +y-T run zsh -c "echo -n '${media-title} [${time-pos}/${duration}]' | xclip -sel c"; show-text "Copied title+timestamp to clipboard" +# time-code with fractions +#y-f run python -c "import pyperclip; timeform = r\"${time-pos}\"; timemill = r\"${=time-pos}\"; time = timeform+timemill[-7:-3]; pyperclip.copy(time)"; show-text "Copied timestamp to clipboard" + +# Open help and config files +F1 run xdg-open https://mpv.io/manual/stable/ +F2 run nvim ~/.config/mpv/mpv.conf +F3 run nvim ~/.config/mpv/input.conf +F4 run nvim ~/.config/mpv/default-input.conf + +### SCRIPTS ### +#r script-message-to reload reload_resume +#Alt+w script-message-to webm display-webm-encoder +#Alt+r script-message-to cycle_video_rotate Cycle_Video_Rotate 90 +#z-p script-message-to pitchcontrol toggle +#Alt+c script-message-to crop start-crop +#Ctrl+v script-message-to appendURL appendURL +#G script-binding seek_to/toggle-seeker + +#Alt+g script-binding sponsorblock/set_segment +#Alt+G script-binding sponsorblock/submit_segment +#Alt+h script-binding sponsorblock/upvote +#Alt+H script-binding sponsorblock/downvote + +### uosc ### +#tab script-binding uosc/peek-timeline +#MBTN_MID script-binding uosc/menu +#menu script-binding uosc/menu + +#p script-binding uosc/playlist #! Navigate playlist +#c script-binding uosc/chapters #! Navigate chapters +#v script-binding uosc/video #! Tracks > Select video +#Ctrl+a script-binding uosc/audio #! Tracks > Select audio +#Ctl+s script-binding uosc/subtitles #! Tracks > Select subtitles +# script-binding uosc/load-subtitles #! Tracks > Load subtitles +# script-binding uosc/navigate-directory #! Utils > Navigate directory +# script-binding uosc/show-in-directory #! Utils > Show in directory +#q quit #! Quit + +## Cheat sheet for bindings bound by scripts directly ## +# Ctrl+f change ytdl quality +# Ctrl+r reload in-place / also bound to "r" above +# +## Playlist manager ## +# Shift+ enter open/close +# p save playlist to $XDG_CACHE_DIR/mpv +# Ctrl+p sort playlist +# Backspace remove current item +# RIGHT select item to move in the playlist (using up/down) +# ü vf toggle format=yuv420p,vapoursynth=~~/motioninterpolation.vpy:4:4 +# ü cycle interpolation +# Ü cycle-values tscale "oversample" "linear" "catmull_rom" "mitchell" + + +# Alt+ü vf toggle format=yuv420p,vapoursynth="~/.config/mpv/filters/motioninterpolation.vpy":4:4 +# Alt+ü vf toggle format=yuv420p,vapoursynth="~/.config/mpv/filters/mvtools.vpy":4:4 diff --git a/.config/mpv/mpv.conf b/.config/mpv/mpv.conf new file mode 100644 index 0000000..b2f8712 --- /dev/null +++ b/.config/mpv/mpv.conf @@ -0,0 +1,117 @@ +# profile=svp +# profile=smoothmotion + +# written by fennek182 +# version 1 +# last change: Wed 2026-01-21 + +#[smoothmotion] +# see 2.1.2 High quality configurations +# https://wiki.archlinux.org/title/mpv#High-quality-configurations +#profile=high-quality +#video-sync=display-resample +#interpolation +#tscale=oversample + +# vf=vapoursynth="~/.config/mpv/filters/mvtools.vpy":buffered-frames=4:concurrent-frames=4 + +# video-sync=display-resample +# interpolation = yes +# tscale = box +# tscale-clamp = 0.0 +# tscale-radius = 1.0 +# tscale-window = sphinx + +# ========== PROFILES ========== +# [svp] +# input-ipc-server=/tmp/mpvsocket + +hr-seek-framedrop=no +# watch-later-options-remove=vf +hwdec=auto-copy +hwdec-codecs=all + +# [FrameInterpolation] +# vf=vapoursynth="~/.config/mpv/filters/mvtools.vpy" +# profile-restore = copy +# ============================================ + +resume-playback=no +volume-max=200 +cache=yes +cache=auto +keep-open +# set max cache size to 20 Gigabytes +demuxer-max-bytes=21474836480 + +# ============== YT-DLP ============== +# limit yt-dlp to 1440p60 +# ytdl-format=bestvideo[height<=?1440]+bestaudio/best +ytdl-format=bestvideo+bestaudio/best +# ============== YT-DLP ============== + +# open window immediately (not waiting for yt-dlp) +force-window=immediate + +# # for stupid fucking braindead audbile drm bs +demuxer-lavf-o=activation_bytes=162dfd19 + +# Can fix stuttering in some cases, in other cases probably causes it. Try it if you experience stuttering. +# opengl-early-flush=yes + +# if using mpv_thumbnail_script +# https://github.com/TheAMM/mpv_thumbnail_script +# then mpv's built-in osc must be disabled +# osc=no + +# =============== TEST HDR 2026-01-21 ============== +# Renderer: +# gpu-next requires the latest (unreleased) git version of libplacebo and the latest (unreleased) git version of mpv. +# It's still in testing/fixing/development stages and isn't recommended unless people want to test unfinished, bleeding-edge code. +# The main benefits of GPU-Next are that it has much more efficient rendering code, and that it supports HDR tonemapping, including HDR/DV, and including HDR/DV to SDR (useful for Linux users since Linux lacks HDR support). +# differences gpu/gpu-next: https://github.com/mpv-player/mpv/wiki/GPU-Next-vs-GPU + +# vo=gpu-next +# target-colorspace-hint=yes + +# get apis by "mpv --gpu-api=help": +# gpu-api=vulkan +# gpu-context=waylandvk +# =============== TEST HDR 2026-01-21 ============== + + +# ----------- old - delete +# --keepaspect-window (the default) will lock the window size to the video aspect. +# --no-keepaspect-window disables this behavior, and will instead add black bars if window aspect and video aspect mismatch. +# Whether this actually works depends on the VO backend. (Ignored in fullscreen mode.) +# no-keepaspect-window + +# target-colorspace-hint=yes +# --tone-mapping=spline +# profile=gpu-hq + +# always open a new instance of mpv instead of replacing currently playing video +#process-instance=multi + +# use yt-dlp instead of youtube-dl since it's slow now (Oct 21) +# script-opts=ytdl_hook-ytdl_path=/usr/bin/yt-dlp + +# catppuccin colors: +# Main mpv options +# osd-color='#cdd6f4' +# osd-shadow-color='#1e1e2e' + +# ; # Stats script options +# ; # Options are on separate lines for clarity +# ; # Colors are in #BBGGRR format +# ; script-opts-append=stats-border_color=251818 +# ; script-opts-append=stats-font_color=f4d6cd +# ; script-opts-append=stats-plot_bg_border_color=d5e294 +# ; script-opts-append=stats-plot_bg_color=251818 +# ; script-opts-append=stats-plot_color=d5e294 +# ; +# ; # External script options +# ; # It is fine to leave these here even if one does not use these scripts because they are just ignored unless a script uses them +# ; +# ; # UOSC options +# ; script-opts-append=uosc-color=foreground=94e2d5,foreground_text=313244,background=1e1e2e,background_text=cdd6f4,curtain=181825,success=a6e3a1,error=f38ba8 diff --git a/.config/mpv/sample-vids/24fps.mkv b/.config/mpv/sample-vids/24fps.mkv new file mode 100644 index 0000000..457231d Binary files /dev/null and b/.config/mpv/sample-vids/24fps.mkv differ diff --git a/.config/mpv/scripts/mpvSockets.lua b/.config/mpv/scripts/mpvSockets.lua new file mode 100644 index 0000000..df8d078 --- /dev/null +++ b/.config/mpv/scripts/mpvSockets.lua @@ -0,0 +1,36 @@ +-- mpvSockets, one socket per instance, removes socket on exit + +local utils = require 'mp.utils' + +local function get_temp_path() + local directory_seperator = package.config:match("([^\n]*)\n?") + local example_temp_file_path = os.tmpname() + + -- remove generated temp file + pcall(os.remove, example_temp_file_path) + + local seperator_idx = example_temp_file_path:reverse():find(directory_seperator) + local temp_path_length = #example_temp_file_path - seperator_idx + + return example_temp_file_path:sub(1, temp_path_length) +end + +tempDir = get_temp_path() + +function join_paths(...) + local arg={...} + path = "" + for i,v in ipairs(arg) do + path = utils.join_path(path, tostring(v)) + end + return path; +end + +ppid = utils.getpid() +os.execute("mkdir " .. join_paths(tempDir, "mpvSockets") .. " 2>/dev/null") +mp.set_property("options/input-ipc-server", join_paths(tempDir, "mpvSockets", ppid)) + +function shutdown_handler() + os.remove(join_paths(tempDir, "mpvSockets", ppid)) +end +mp.register_event("shutdown", shutdown_handler) diff --git a/.config/mpv/scripts/sponsorblock.lua b/.config/mpv/scripts/sponsorblock.lua new file mode 100644 index 0000000..aea7f80 --- /dev/null +++ b/.config/mpv/scripts/sponsorblock.lua @@ -0,0 +1,568 @@ +-- sponsorblock.lua +-- +-- This script skips sponsored segments of YouTube videos +-- using data from https://github.com/ajayyy/SponsorBlock + +local ON_WINDOWS = package.config:sub(1,1) ~= "/" + +local options = { + server_address = "https://sponsor.ajay.app", + + python_path = ON_WINDOWS and "python" or "python3", + + -- Categories to fetch + categories = "sponsor,intro,outro,interaction,selfpromo,filler", + + -- Categories to skip automatically + skip_categories = "sponsor,intro,outro,interaction,selfpromo", + + -- If true, sponsored segments will only be skipped once + skip_once = true, + + -- Note that sponsored segments may ocasionally be inaccurate if this is turned off + -- see https://blog.ajay.app/voting-and-pseudo-randomness-or-sponsorblock-or-youtube-sponsorship-segment-blocker + local_database = false, + + -- Update database on first run, does nothing if local_database is false + auto_update = true, + + -- How long to wait between local database updates + -- Format: "X[d,h,m]", leave blank to update on every mpv run + auto_update_interval = "6h", + + -- User ID used to submit sponsored segments, leave blank for random + user_id = "", + + -- Name to display on the stats page https://sponsor.ajay.app/stats/ leave blank to keep current name + display_name = "", + + -- Tell the server when a skip happens + report_views = true, + + -- Auto upvote skipped sponsors + auto_upvote = false, + + -- Use sponsor times from server if they're more up to date than our local database + server_fallback = true, + + -- Create chapters at sponsor boundaries for OSC display and manual skipping + make_chapters = true, + + -- Minimum duration for sponsors (in seconds), segments under that threshold will be ignored + min_duration = 1, + + -- Fade audio for smoother transitions + audio_fade = false, + + -- Audio fade step, applied once every 100ms until cap is reached + audio_fade_step = 10, + + -- Audio fade cap + audio_fade_cap = 0, + + -- Fast forward through sponsors instead of skipping + fast_forward = false, + + -- Playback speed modifier when fast forwarding, applied once every second until cap is reached + fast_forward_increase = .2, + + -- Playback speed cap + fast_forward_cap = 2, + + -- Length of the sha256 prefix (3-32) when querying server, 0 to disable + sha256_length = 4, + + -- Pattern for video id in local files, ignored if blank + -- Recommended value for base youtube-dl is "-([%w-_]+)%.[mw][kpe][v4b]m?$" + local_pattern = "", + + -- Legacy option, use skip_categories instead + skip = true +} + +mp.options = require "mp.options" +mp.options.read_options(options, "sponsorblock") + +local legacy = mp.command_native_async == nil +--[[ +if legacy then + options.local_database = false +end +--]] +options.local_database = false + +local utils = require "mp.utils" +scripts_dir = mp.find_config_file("scripts") + +local sponsorblock = utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.py") +local uid_path = utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.txt") +local database_file = options.local_database and utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.db") or "" +local youtube_id = nil +local ranges = {} +local init = false +local segment = {a = 0, b = 0, progress = 0, first = true} +local retrying = false +local last_skip = {uuid = "", dir = nil} +local speed_timer = nil +local fade_timer = nil +local fade_dir = nil +local volume_before = mp.get_property_number("volume") +local categories = {} +local all_categories = {"sponsor", "intro", "outro", "interaction", "selfpromo", "preview", "music_offtopic", "filler"} +local chapter_cache = {} + +for category in string.gmatch(options.skip_categories, "([^,]+)") do + categories[category] = true +end + +function file_exists(name) + local f = io.open(name,"r") + if f ~= nil then io.close(f) return true else return false end +end + +function t_count(t) + local count = 0 + for _ in pairs(t) do count = count + 1 end + return count +end + +function time_sort(a, b) + if a.time == b.time then + return string.match(a.title, "segment end") + end + return a.time < b.time +end + +function parse_update_interval() + local s = options.auto_update_interval + if s == "" then return 0 end -- Interval Disabled + + local num, mod = s:match "^(%d+)([hdm])$" + + if num == nil or mod == nil then + mp.osd_message("[sponsorblock] auto_update_interval " .. s .. " is invalid", 5) + return nil + end + + local time_table = { + m = 60, + h = 60 * 60, + d = 60 * 60 * 24, + } + + return num * time_table[mod] +end + +function clean_chapters() + local chapters = mp.get_property_native("chapter-list") + local new_chapters = {} + for _, chapter in pairs(chapters) do + if chapter.title ~= "Preview segment start" and chapter.title ~= "Preview segment end" then + table.insert(new_chapters, chapter) + end + end + mp.set_property_native("chapter-list", new_chapters) +end + +function create_chapter(chapter_title, chapter_time) + local chapters = mp.get_property_native("chapter-list") + local duration = mp.get_property_native("duration") + table.insert(chapters, {title=chapter_title, time=(duration == nil or duration > chapter_time) and chapter_time or duration - .001}) + table.sort(chapters, time_sort) + mp.set_property_native("chapter-list", chapters) +end + +function process(uuid, t, new_ranges) + start_time = tonumber(string.match(t, "[^,]+")) + end_time = tonumber(string.sub(string.match(t, ",[^,]+"), 2)) + for o_uuid, o_t in pairs(ranges) do + if (start_time >= o_t.start_time and start_time <= o_t.end_time) or (o_t.start_time >= start_time and o_t.start_time <= end_time) then + new_ranges[o_uuid] = o_t + return + end + end + category = string.match(t, "[^,]+$") + if categories[category] and end_time - start_time >= options.min_duration then + new_ranges[uuid] = { + start_time = start_time, + end_time = end_time, + category = category, + skipped = false + } + end + if options.make_chapters and not chapter_cache[uuid] then + chapter_cache[uuid] = true + local category_title = (category:gsub("^%l", string.upper):gsub("_", " ")) + create_chapter(category_title .. " segment start (" .. string.sub(uuid, 1, 6) .. ")", start_time) + create_chapter(category_title .. " segment end (" .. string.sub(uuid, 1, 6) .. ")", end_time) + end +end + +function getranges(_, exists, db, more) + if type(exists) == "table" and exists["status"] == "1" then + if options.server_fallback then + mp.add_timeout(0, function() getranges(true, true, "") end) + else + return mp.osd_message("[sponsorblock] database update failed, gave up") + end + end + if db ~= "" and db ~= database_file then db = database_file end + if exists ~= true and not file_exists(db) then + if not retrying then + mp.osd_message("[sponsorblock] database update failed, retrying...") + retrying = true + end + return update() + end + if retrying then + mp.osd_message("[sponsorblock] database update succeeded") + retrying = false + end + local sponsors + local args = { + options.python_path, + sponsorblock, + "ranges", + db, + options.server_address, + youtube_id, + options.categories, + tostring(options.sha256_length) + } + if not legacy then + sponsors = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args}) + else + sponsors = utils.subprocess({args = args}) + end + mp.msg.debug("Got: " .. string.gsub(sponsors.stdout, "[\n\r]", "")) + if not string.match(sponsors.stdout, "^%s*(.*%S)") then return end + if string.match(sponsors.stdout, "error") then return getranges(true, true) end + local new_ranges = {} + local r_count = 0 + if more then r_count = -1 end + for t in string.gmatch(sponsors.stdout, "[^:%s]+") do + uuid = string.match(t, "([^,]+),[^,]+$") + if ranges[uuid] then + new_ranges[uuid] = ranges[uuid] + else + process(uuid, t, new_ranges) + end + r_count = r_count + 1 + end + local c_count = t_count(ranges) + if c_count == 0 or r_count >= c_count then + ranges = new_ranges + end +end + +function fast_forward() + if options.fast_forward and options.fast_forward == true then + speed_timer = nil + mp.set_property("speed", 1) + end + local last_speed = mp.get_property_number("speed") + local new_speed = math.min(last_speed + options.fast_forward_increase, options.fast_forward_cap) + if new_speed <= last_speed then return end + mp.set_property("speed", new_speed) +end + +function fade_audio(step) + local last_volume = mp.get_property_number("volume") + local new_volume = math.max(options.audio_fade_cap, math.min(last_volume + step, volume_before)) + if new_volume == last_volume then + if step >= 0 then fade_dir = nil end + if fade_timer ~= nil then fade_timer:kill() end + fade_timer = nil + return + end + mp.set_property("volume", new_volume) +end + +function skip_ads(name, pos) + if pos == nil then return end + local sponsor_ahead = false + for uuid, t in pairs(ranges) do + if (options.fast_forward == uuid or not options.skip_once or not t.skipped) and t.start_time <= pos and t.end_time > pos then + if options.fast_forward == uuid then return end + if options.fast_forward == false then + mp.osd_message("[sponsorblock] " .. t.category .. " skipped") + mp.set_property("time-pos", t.end_time) + else + mp.osd_message("[sponsorblock] skipping " .. t.category) + end + t.skipped = true + last_skip = {uuid = uuid, dir = nil} + if options.report_views or options.auto_upvote then + local args = { + options.python_path, + sponsorblock, + "stats", + database_file, + options.server_address, + youtube_id, + uuid, + options.report_views and "1" or "", + uid_path, + options.user_id, + options.auto_upvote and "1" or "" + } + if not legacy then + mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) + else + utils.subprocess_detached({args = args}) + end + end + if options.fast_forward ~= false then + options.fast_forward = uuid + if speed_timer ~= nil then speed_timer:kill() end + speed_timer = mp.add_periodic_timer(1, fast_forward) + end + return + elseif (not options.skip_once or not t.skipped) and t.start_time <= pos + 1 and t.end_time > pos + 1 then + sponsor_ahead = true + end + end + if options.audio_fade then + if sponsor_ahead then + if fade_dir ~= false then + if fade_dir == nil then volume_before = mp.get_property_number("volume") end + if fade_timer ~= nil then fade_timer:kill() end + fade_dir = false + fade_timer = mp.add_periodic_timer(.1, function() fade_audio(-options.audio_fade_step) end) + end + elseif fade_dir == false then + fade_dir = true + if fade_timer ~= nil then fade_timer:kill() end + fade_timer = mp.add_periodic_timer(.1, function() fade_audio(options.audio_fade_step) end) + end + end + if options.fast_forward and options.fast_forward ~= true then + options.fast_forward = true + speed_timer:kill() + speed_timer = nil + mp.set_property("speed", 1) + end +end + +function vote(dir) + if last_skip.uuid == "" then return mp.osd_message("[sponsorblock] no sponsors skipped, can't submit vote") end + local updown = dir == "1" and "up" or "down" + if last_skip.dir == dir then return mp.osd_message("[sponsorblock] " .. updown .. "vote already submitted") end + last_skip.dir = dir + local args = { + options.python_path, + sponsorblock, + "stats", + database_file, + options.server_address, + youtube_id, + last_skip.uuid, + "", + uid_path, + options.user_id, + dir + } + if not legacy then + mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) + else + utils.subprocess({args = args}) + end + mp.osd_message("[sponsorblock] " .. updown .. "vote submitted") +end + +function update() + mp.command_native_async({name = "subprocess", playback_only = false, args = { + options.python_path, + sponsorblock, + "update", + database_file, + options.server_address + }}, getranges) +end + +function file_loaded() + local initialized = init + ranges = {} + segment = {a = 0, b = 0, progress = 0, first = true} + last_skip = {uuid = "", dir = nil} + chapter_cache = {} + local video_path = mp.get_property("path", "") + mp.msg.debug("Path: " .. video_path) + local video_referer = string.match(mp.get_property("http-header-fields", ""), "Referer:([^,]+)") or "" + mp.msg.debug("Referer: " .. video_referer) + + local urls = { + "https?://youtu%.be/([%w-_]+).*", + "https?://w?w?w?%.?youtube%.com/v/([%w-_]+).*", + "/watch.*[?&]v=([%w-_]+).*", + "/embed/([%w-_]+).*" + } + youtube_id = nil + for i, url in ipairs(urls) do + youtube_id = youtube_id or string.match(video_path, url) or string.match(video_referer, url) + if youtube_id then break end + end + youtube_id = youtube_id or string.match(video_path, options.local_pattern) + + if not youtube_id or string.len(youtube_id) < 11 or (local_pattern and string.len(youtube_id) ~= 11) then return end + youtube_id = string.sub(youtube_id, 1, 11) + mp.msg.debug("Found YouTube ID: " .. youtube_id) + init = true + if not options.local_database then + getranges(true, true) + else + local exists = file_exists(database_file) + if exists and options.server_fallback then + getranges(true, true) + mp.add_timeout(0, function() getranges(true, true, "", true) end) + elseif exists then + getranges(true, true) + elseif options.server_fallback then + mp.add_timeout(0, function() getranges(true, true, "") end) + end + end + if initialized then return end + if options.skip then + mp.observe_property("time-pos", "native", skip_ads) + end + if options.display_name ~= "" then + local args = { + options.python_path, + sponsorblock, + "username", + database_file, + options.server_address, + youtube_id, + "", + "", + uid_path, + options.user_id, + options.display_name + } + if not legacy then + mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end) + else + utils.subprocess_detached({args = args}) + end + end + if not options.local_database or (not options.auto_update and file_exists(database_file)) then return end + + if file_exists(database_file) then + local db_info = utils.file_info(database_file) + local cur_time = os.time(os.date("*t")) + local upd_interval = parse_update_interval() + if upd_interval == nil or os.difftime(cur_time, db_info.mtime) < upd_interval then return end + end + + update() +end + +function set_segment() + if not youtube_id then return end + local pos = mp.get_property_number("time-pos") + if pos == nil then return end + if segment.progress > 1 then + segment.progress = segment.progress - 2 + end + if segment.progress == 1 then + segment.progress = 0 + segment.b = pos + mp.osd_message("[sponsorblock] segment boundary B set, press again for boundary A", 3) + else + segment.progress = 1 + segment.a = pos + mp.osd_message("[sponsorblock] segment boundary A set, press again for boundary B", 3) + end + if options.make_chapters and not segment.first then + local start_time = math.min(segment.a, segment.b) + local end_time = math.max(segment.a, segment.b) + if end_time - start_time ~= 0 and end_time ~= 0 then + clean_chapters() + create_chapter("Preview segment start", start_time) + create_chapter("Preview segment end", end_time) + end + end + segment.first = false +end + +function select_category(selected) + for category in string.gmatch(options.categories, "([^,]+)") do + mp.remove_key_binding("select_category_"..category) + mp.remove_key_binding("kp_select_category_"..category) + end + submit_segment(selected) +end + +function submit_segment(category) + if not youtube_id then return end + local start_time = math.min(segment.a, segment.b) + local end_time = math.max(segment.a, segment.b) + if end_time - start_time == 0 or end_time == 0 then + mp.osd_message("[sponsorblock] empty segment, not submitting") + elseif segment.progress <= 1 then + segment.progress = segment.progress + 2 + local category_list = "" + for category_id, category in pairs(all_categories) do + local category_title = (category:gsub("^%l", string.upper):gsub("_", " ")) + category_list = category_list .. category_id .. ": " .. category_title .. "\n" + mp.add_forced_key_binding(tostring(category_id), "select_category_"..category, function() select_category(category) end) + mp.add_forced_key_binding("KP"..tostring(category_id), "kp_select_category_"..category, function() select_category(category) end) + end + mp.osd_message(string.format("[sponsorblock] press a number to select category for segment: %.2d:%.2d:%.2d to %.2d:%.2d:%.2d\n\n" .. category_list .. "\nyou can press Shift+G again for default (Sponsor) or hide this message with g", math.floor(start_time/(60*60)), math.floor(start_time/60%60), math.floor(start_time%60), math.floor(end_time/(60*60)), math.floor(end_time/60%60), math.floor(end_time%60)), 30) + else + mp.osd_message("[sponsorblock] submitting segment...", 30) + local submit + local args = { + options.python_path, + sponsorblock, + "submit", + database_file, + options.server_address, + youtube_id, + tostring(start_time), + tostring(end_time), + uid_path, + options.user_id, + category or "sponsor" + } + if not legacy then + submit = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args}) + else + submit = utils.subprocess({args = args}) + end + if string.match(submit.stdout, "success") then + segment = {a = 0, b = 0, progress = 0, first = true} + mp.osd_message("[sponsorblock] segment submitted") + if options.make_chapters then + clean_chapters() + create_chapter("Submitted segment start", start_time) + create_chapter("Submitted segment end", end_time) + end + elseif string.match(submit.stdout, "error") then + mp.osd_message("[sponsorblock] segment submission failed, server may be down. try again", 5) + elseif string.match(submit.stdout, "502") then + mp.osd_message("[sponsorblock] segment submission failed, server is down. try again", 5) + elseif string.match(submit.stdout, "400") then + mp.osd_message("[sponsorblock] segment submission failed, impossible inputs", 5) + segment = {a = 0, b = 0, progress = 0, first = true} + elseif string.match(submit.stdout, "429") then + mp.osd_message("[sponsorblock] segment submission failed, rate limited. try again", 5) + elseif string.match(submit.stdout, "409") then + mp.osd_message("[sponsorblock] segment already submitted", 3) + segment = {a = 0, b = 0, progress = 0, first = true} + else + mp.osd_message("[sponsorblock] segment submission failed", 5) + end + end +end + +mp.register_event("file-loaded", file_loaded) +mp.add_key_binding("g", "set_segment", set_segment) +mp.add_key_binding("G", "submit_segment", submit_segment) +mp.add_key_binding("h", "upvote_segment", function() return vote("1") end) +mp.add_key_binding("H", "downvote_segment", function() return vote("0") end) +-- Bindings below are for backwards compatibility and could be removed at any time +mp.add_key_binding(nil, "sponsorblock_set_segment", set_segment) +mp.add_key_binding(nil, "sponsorblock_submit_segment", submit_segment) +mp.add_key_binding(nil, "sponsorblock_upvote", function() return vote("1") end) +mp.add_key_binding(nil, "sponsorblock_downvote", function() return vote("0") end) diff --git a/.config/mpv/scripts/sponsorblock_shared/main.lua b/.config/mpv/scripts/sponsorblock_shared/main.lua new file mode 100644 index 0000000..2bbe7a2 --- /dev/null +++ b/.config/mpv/scripts/sponsorblock_shared/main.lua @@ -0,0 +1,3 @@ +-- This is a dummy main.lua +-- required for mpv 0.33 +-- do not delete \ No newline at end of file diff --git a/.config/mpv/scripts/sponsorblock_shared/sponsorblock.py b/.config/mpv/scripts/sponsorblock_shared/sponsorblock.py new file mode 100644 index 0000000..8370a6a --- /dev/null +++ b/.config/mpv/scripts/sponsorblock_shared/sponsorblock.py @@ -0,0 +1,122 @@ +import urllib.request +import urllib.parse +import hashlib +import sqlite3 +import random +import string +import json +import sys +import os + +if sys.argv[1] in ["submit", "stats", "username"]: + if not sys.argv[8]: + if os.path.isfile(sys.argv[7]): + with open(sys.argv[7]) as f: + uid = f.read() + else: + uid = "".join(random.choices(string.ascii_letters + string.digits, k=36)) + with open(sys.argv[7], "w") as f: + f.write(uid) + else: + uid = sys.argv[8] + +opener = urllib.request.build_opener() +opener.addheaders = [("User-Agent", "mpv_sponsorblock/1.0 (https://github.com/po5/mpv_sponsorblock)")] +urllib.request.install_opener(opener) + +if sys.argv[1] == "ranges" and (not sys.argv[2] or not os.path.isfile(sys.argv[2])): + sha = None + if 3 <= int(sys.argv[6]) <= 32: + sha = hashlib.sha256(sys.argv[4].encode()).hexdigest()[:int(sys.argv[6])] + times = [] + try: + response = urllib.request.urlopen(sys.argv[3] + "/api/skipSegments" + ("/" + sha + "?" if sha else "?videoID=" + sys.argv[4] + "&") + urllib.parse.urlencode([("categories", json.dumps(sys.argv[5].split(",")))])) + segments = json.load(response) + for segment in segments: + if sha and sys.argv[4] != segment["videoID"]: + continue + if sha: + for s in segment["segments"]: + times.append(str(s["segment"][0]) + "," + str(s["segment"][1]) + "," + s["UUID"] + "," + s["category"]) + else: + times.append(str(segment["segment"][0]) + "," + str(segment["segment"][1]) + "," + segment["UUID"] + "," + segment["category"]) + print(":".join(times)) + except (TimeoutError, urllib.error.URLError) as e: + print("error") + except urllib.error.HTTPError as e: + if e.code == 404: + print("") + else: + print("error") +elif sys.argv[1] == "ranges": + conn = sqlite3.connect(sys.argv[2]) + conn.row_factory = sqlite3.Row + c = conn.cursor() + times = [] + for category in sys.argv[5].split(","): + c.execute("SELECT startTime, endTime, votes, UUID, category FROM sponsorTimes WHERE videoID = ? AND shadowHidden = 0 AND votes > -1 AND category = ?", (sys.argv[4], category)) + sponsors = c.fetchall() + best = list(sponsors) + dealtwith = [] + similar = [] + for sponsor_a in sponsors: + for sponsor_b in sponsors: + if sponsor_a is not sponsor_b and sponsor_a["startTime"] >= sponsor_b["startTime"] and sponsor_a["startTime"] <= sponsor_b["endTime"]: + similar.append([sponsor_a, sponsor_b]) + if sponsor_a in best: + best.remove(sponsor_a) + if sponsor_b in best: + best.remove(sponsor_b) + for sponsors_a in similar: + if sponsors_a in dealtwith: + continue + group = set(sponsors_a) + for sponsors_b in similar: + if sponsors_b[0] in group or sponsors_b[1] in group: + group.add(sponsors_b[0]) + group.add(sponsors_b[1]) + dealtwith.append(sponsors_b) + best.append(max(group, key=lambda x:x["votes"])) + for time in best: + times.append(str(time["startTime"]) + "," + str(time["endTime"]) + "," + time["UUID"] + "," + time["category"]) + print(":".join(times)) +elif sys.argv[1] == "update": + try: + urllib.request.urlretrieve(sys.argv[3] + "/database.db", sys.argv[2] + ".tmp") + os.replace(sys.argv[2] + ".tmp", sys.argv[2]) + except PermissionError: + print("database update failed, file currently in use", file=sys.stderr) + sys.exit(1) + except ConnectionResetError: + print("database update failed, connection reset", file=sys.stderr) + sys.exit(1) + except TimeoutError: + print("database update failed, timed out", file=sys.stderr) + sys.exit(1) + except urllib.error.URLError: + print("database update failed", file=sys.stderr) + sys.exit(1) +elif sys.argv[1] == "submit": + try: + req = urllib.request.Request(sys.argv[3] + "/api/skipSegments", data=json.dumps({"videoID": sys.argv[4], "segments": [{"segment": [float(sys.argv[5]), float(sys.argv[6])], "category": sys.argv[9]}], "userID": uid}).encode(), headers={"Content-Type": "application/json"}) + response = urllib.request.urlopen(req) + print("success") + except urllib.error.HTTPError as e: + print(e.code) + except: + print("error") +elif sys.argv[1] == "stats": + try: + if sys.argv[6]: + urllib.request.urlopen(sys.argv[3] + "/api/viewedVideoSponsorTime?UUID=" + sys.argv[5]) + if sys.argv[9]: + urllib.request.urlopen(sys.argv[3] + "/api/voteOnSponsorTime?UUID=" + sys.argv[5] + "&userID=" + uid + "&type=" + sys.argv[9]) + except: + pass +elif sys.argv[1] == "username": + try: + data = urllib.parse.urlencode({"userID": uid, "userName": sys.argv[9]}).encode() + req = urllib.request.Request(sys.argv[3] + "/api/setUsername", data=data) + urllib.request.urlopen(req) + except: + pass diff --git a/.config/mpv/scripts/sponsorblock_shared/sponsorblock.txt b/.config/mpv/scripts/sponsorblock_shared/sponsorblock.txt new file mode 100644 index 0000000..3005870 --- /dev/null +++ b/.config/mpv/scripts/sponsorblock_shared/sponsorblock.txt @@ -0,0 +1 @@ +iUa1tBJrcHEXyi4YFO5OjUaXy57xRu9QSbNp \ No newline at end of file