diff --git a/.editorconfig b/.editorconfig index 3b0653a..a8ced1c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,4 +6,10 @@ indent_size = 4 end_of_line = lf charset = utf-8 trim_trailing_whitespace = false -insert_final_newline = false \ No newline at end of file +insert_final_newline = false + +[{Makefile}] +indent_style = tab + +[{*.html,*.js,*.json,*.css}] +indent_size = 2 \ No newline at end of file diff --git a/Makefile b/Makefile index 7ccbe43..56694d4 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ SRC:=config.go ipc.go options.go $(wildcard cmd/open-in-mpv/*) EXT_SRC:=$(wildcard extension/Chrome/*) extension/Firefox/manifest.json +SCRIPTS_DIR:=scripts all: build/linux.tar build/mac.tar build/windows.tar build/firefox.zip @@ -9,8 +10,8 @@ builddir: build/linux/open-in-mpv: $(SRC) builddir @echo -e "\n# Building for Linux" env CGO_ENABLED=0 GOOS=linux GOARCh=amd64 go build -ldflags="-s -w" -o $@ ./cmd/open-in-mpv - cp scripts/install-protocol.sh $(dir $@) - cp scripts/open-in-mpv.desktop $(dir $@) + cp $(SCRIPTS_DIR)/install-protocol.sh $(dir $@) + cp $(SCRIPTS_DIR)/open-in-mpv.desktop $(dir $@) build/linux.tar: build/linux/open-in-mpv tar cf $@ -C $(dir $@)linux $(notdir $(wildcard build/linux/*)) @@ -22,7 +23,7 @@ build/mac/open-in-mpv.app: $(SRC) scripts/Info.plist builddir @mkdir -p $@/Contents env CGO_ENABLED=0 GOOS=darwin GOARCh=amd64 go build -ldflags="-s -w" -o $@/Contents/MacOS/open-in-mpv ./cmd/open-in-mpv cp config.yml $@/Contents/MacOS/ - cp scripts/Info.plist $@/Contents + cp $(SCRIPTS_DIR)/Info.plist $@/Contents build/mac.tar: build/mac/open-in-mpv.app tar cf $@ -C $(dir $@)/mac open-in-mpv.app @@ -30,7 +31,7 @@ build/mac.tar: build/mac/open-in-mpv.app build/windows/open-in-mpv.exe: $(SRC) builddir @echo -e "\n# Building for Windows" env CGO_ENABLED=0 GOOS=windows GOARCh=amd64 go build -ldflags="-s -w -H windowsgui" -o $@ ./cmd/open-in-mpv - cp scripts/install-protocol.reg $(dir $@) + cp $(SCRIPTS_DIR)/install-protocol.reg $(dir $@) build/windows.tar: build/windows/open-in-mpv.exe tar cf $@ -C $(dir $@)windows $(notdir $(wildcard build/windows/*)) @@ -45,7 +46,7 @@ install: build/linux/open-in-mpv cp build/linux/open-in-mpv /usr/bin install-protocol: - scripts/install-protocol.sh + $(SCRIPTS_DIR)/install-protocol.sh uninstall: rm /usr/bin/open-in-mpv diff --git a/README.md b/README.md index 9d9d5a1..f041e23 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,14 @@ This is a simple web extension (for Chrome and Firefox) which helps open any video in the currently open tab in the [mpv player](https://mpv.io). -The extension itself shares a lot of code with the one from the awesome [iina](https://github.com/iina/iina), while the (bare) backend is written in Go (this is a rewrite from C++). +The extension itself shares a lot of code with the one from the awesome [iina](https://github.com/iina/iina), while the (bare) native binary is written in Go (this is a rewrite from C++). - [Installation](#installation) - [Configuration](#configuration) - - [Flag overrides](#flag-overrides) - - [Example](#example) - [The `mpv://` protocol](#the-mpv-protocol) - [Playlist and `enqueue` functionality](#playlist-and-enqueue-functionality) - [Player support](#player-support) +- [Supported protocols](#supported-protocols) ### Installation > Compiled binaries and packed extensions can be found in the [releases page](https://github.com/Baldomo/open-in-mpv/releases). @@ -38,31 +37,33 @@ The configuration file has to be named `config.yml` and can be placed in the sam The configuration file has the following structure: ```yaml -fake: [ open_in_mpv.Player ] - name: [ string ] - executable: [ string ] - fullscreen: [ string ] - pip: [ string ] - enqueue: [ string ] - new_window: [ string ] - needs_ipc: [ true | false ] - flag_overrides: [ map[string]string ] +fake: # open_in_mpv.Player + name: # string + executable: # string + fullscreen: # string + pip: # string + enqueue: # string + new_window: # string + needs_ipc: # true | false + supported_protocols: # []string + flag_overrides: # map[string]string ``` -> See [the default configuration](config.yml) as an example +> See [the default configuration](config.yml) or the [example](#example) below And the `open_in_mpv.Player` object is defined as follows: -| Key | Example value | Description | -|---------------|----------------------------------------|--------------| -| `name` | `mpv` | Full name of the video player; not used internally | -| `executable` | `mpv` | The player's binary path/name (doesn't need full path if already in `$PATH`) | -| `fullscreen` | `"--fs"` | Flag override to open the player in fullscreen (can be empty) | -| `pip` | `"--pip"` | Flag override to open the player in picture-in-picture mode (can be empty) | -| `enqueue` | `"--enqueue"` | Flag override to add the video to the player's queue (can be empty) | -| `new_window` | `"--new-window"` | Flag override to force open a new player window with the video (can be empty) | -| `needs_ipc` | `false` | Controls whether the player needs IPC communication (only generates mpv-compatible JSON commands, used for enqueing videos) | -| `flag_overrides` | `"*": "--mpv-options=%s"` | Defines arbitrary text overrides for command line flags (see below) | +| Key | Example value | Description | +| --------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `name` | `mpv` | Full name of the video player; not used internally | +| `executable` | `mpv` | The player's binary path/name (doesn't need full path if already in `$PATH`) | +| `fullscreen` | `"--fs"` | Flag override to open the player in fullscreen (can be empty) | +| `pip` | `"--pip"` | Flag override to open the player in picture-in-picture mode (can be empty) | +| `enqueue` | `"--enqueue"` | Flag override to add the video to the player's queue (can be empty) | +| `new_window` | `"--new-window"` | Flag override to force open a new player window with the video (can be empty) | +| `needs_ipc` | `false` | Controls whether the player needs IPC communication (only generates mpv-compatible JSON commands, used for enqueing videos) | +| `supported_protocols` | `["http", "https"]` | An arbitrary whitelist of protocols the player supports. See the [relevant section](#supported-protocols) | +| `flag_overrides` | `"*": "--mpv-%s"` | Defines arbitrary text overrides for command line flags (see below) | #### Flag overrides @@ -98,6 +99,11 @@ players: enqueue: "--enqueue" new_window: "" needs_ipc: true + supported_protocols: + - http + - https + - ftp + - ftps flag_overrides: "*": "--mpv-options=%s" ``` @@ -105,7 +111,7 @@ players: ### The `mpv://` protocol `open-in-mpv install-protocol` will create a custom `xdg-open` desktop file with a scheme handler for the `mpv://` protocol. This lets `xdg-open` call `open-in-mpv` with an encoded URI, so that it can be parsed and the information can be relayed to `mpv` - this logic follows how `iina` parses and opens custom URIs with the `iina://` protocol on Mac. `install-protocol.sh` has the same functionality. -Please note that this specification is enforced quite strictly, as the program will error out when: +Please note that this specification is enforced quite strictly, as the program will error out when at least one of the following conditions is true: - The protocol is not `mpv://` - The method/path is not `/open` @@ -113,14 +119,14 @@ Please note that this specification is enforced quite strictly, as the program w The table below is a simple documentation of the URL query keys and values used to let the `open-in-mpv` executable what to do. -| Key | Example value | Description | -|---------------|----------------------------------------|-------------| -| `url` | `https%3A%2F%2Fyoutu.be%2FdQw4w9WgXcQ` | The actual file URL to be played, URL-encoded | -| `full_screen` | `1` | Controls whether the video is played in fullscreen mode | -| `pip` | `1` | Simulates a picture-in-picture mode (only works with mpv for now) | -| `enqueue` | `1` | Adds a video to the queue (see below) | -| `new_window` | `1` | Forcibly starts a video in a new window even if one is already open | -| `player` | `celluloid` | Starts any supported video player (see [Player support](#player-support)) | +| Key | Example value | Description | +| ------------- | -------------------------------------- | ------------------------------------------------------------------------------ | +| `url` | `https%3A%2F%2Fyoutu.be%2FdQw4w9WgXcQ` | The actual file URL to be played, URL-encoded | +| `full_screen` | `1` | Controls whether the video is played in fullscreen mode | +| `pip` | `1` | Simulates a picture-in-picture mode (only works with mpv for now) | +| `enqueue` | `1` | Adds a video to the queue (see below) | +| `new_window` | `1` | Forcibly starts a video in a new window even if one is already open | +| `player` | `celluloid` | Starts any supported video player (see [Player support](#player-support)) | | `flags` | `--vo%3Dgpu` | Custom command options and flags to be passed to the video player, URL-encoded | ### Playlist and `enqueue` functionality @@ -131,4 +137,7 @@ input-ipc-server=/tmp/mpvsocket ``` ### Player support -Supported players are defined in `config.yml`, where the struct `Player` ([see `config.go`](config.go)) defines supported functionality and command line flag overrides. To request support for a player you're welcome to open a new issue or a pull request or just add your own in your configuration file. \ No newline at end of file +Supported players are defined in `config.yml`, where the struct `Player` ([see `config.go`](config.go)) defines supported functionality and command line flag overrides. To request support for a player you're welcome to open a new issue or a pull request or just add your own in your configuration file. + +### Supported protocols +Since opening an arbitrary URL with a shell command can cause remote code execution on the host machine (for example by loading arbitrary `.so` files on a player by using special [schemes](https://en.wikipedia.org/wiki/List_of_URI_schemes)), only protocols/[schemes](https://en.wikipedia.org/wiki/List_of_URI_schemes) explicitly specified in the configuration will be processed by the native binary without errors. Defaults to `["http", "https"]` if empty. There is also no special instructions parsing or catch-all values. \ No newline at end of file diff --git a/config.go b/config.go index e9ad3ce..cbd6b02 100644 --- a/config.go +++ b/config.go @@ -28,6 +28,9 @@ type Player struct { NewWindow string `yaml:"new_window"` // Controls whether this player needs IPC command to enqueue videos NeedsIpc bool `yaml:"needs_ipc"` + // Controls which (video URL) schemes are to be opened by the current + // player. There is no match-all, each protocol has to be manuall specified + SupportedSchemes []string `yaml:"supported_protocols"` // Overrides for any generic flag FlagOverrides map[string]string `yaml:"flag_overrides"` } @@ -37,6 +40,11 @@ type Config struct { Players map[string]Player } +var defaultSupportedSchemas = []string{ + "http", + "https", +} + var defaultConfig = Config{ Players: map[string]Player{ "mpv": { @@ -52,7 +60,8 @@ var defaultConfig = Config{ }, } -// Tries to load configuration file with fallback +// Tries to load configuration file with fallback to a default configuration +// object func LoadConfig() error { confDirs := configdir.New("", "open-in-mpv") confDirs.LocalPath, _ = filepath.Abs(".") @@ -68,11 +77,25 @@ func LoadConfig() error { return err } - return yaml.Unmarshal(data, &defaultConfig) + err = yaml.Unmarshal(data, &defaultConfig) + if err != nil { + return err + } + + // If the player has no external configuration, use strict defaults + for name, player := range defaultConfig.Players { + if len(player.SupportedSchemes) == 0 { + log.Printf("Player '%s' has no schemas, setting to defaults", player.Name) + player.SupportedSchemes = defaultSupportedSchemas + defaultConfig.Players[name] = player + } + } + + return nil } // Returns player information for the given name if present, otherwise nil -func GetPlayerInfo(name string) *Player { +func GetPlayerConfig(name string) *Player { lowerName := strings.ToLower(name) if p, ok := defaultConfig.Players[lowerName]; ok { return &p diff --git a/extension/Chrome/background.html b/extension/Chrome/background.html index adccbe1..d6540a0 100644 --- a/extension/Chrome/background.html +++ b/extension/Chrome/background.html @@ -1,14 +1,17 @@ + - - - - Document - - + + + + Document + + + - + + \ No newline at end of file diff --git a/extension/Chrome/background.js b/extension/Chrome/background.js index 6a9ebd9..e31a9f3 100644 --- a/extension/Chrome/background.js +++ b/extension/Chrome/background.js @@ -3,18 +3,18 @@ import { getOptions, openInMPV, updateBrowserAction } from "./common.js"; updateBrowserAction(); [["page", "pageUrl"], ["link", "linkUrl"], ["video", "srcUrl"], ["audio", "srcUrl"]].forEach(([item, linkType]) => { - chrome.contextMenus.create({ - title: `Open this ${item} in mpv`, - id: `open${item}inmpv`, - contexts: [item], - onclick: (info, tab) => { - getOptions((options) => { - console.log("Got options: ", options); - openInMPV(tab.id, info[linkType], { - mode: options.iconActionOption, - ...options, - }); - }); - }, - }); + chrome.contextMenus.create({ + title: `Open this ${item} in mpv`, + id: `open${item}inmpv`, + contexts: [item], + onclick: (info, tab) => { + getOptions((options) => { + console.log("Got options: ", options); + openInMPV(tab.id, info[linkType], { + mode: options.iconActionOption, + ...options, + }); + }); + }, + }); }); diff --git a/extension/Chrome/common.js b/extension/Chrome/common.js index 26173de..a62aefc 100644 --- a/extension/Chrome/common.js +++ b/extension/Chrome/common.js @@ -1,130 +1,135 @@ class Option { - constructor(name, type, defaultValue) { - this.name = name; - this.type = type; - this.defaultValue = defaultValue; - } + constructor(name, type, defaultValue) { + this.name = name + this.type = type + this.defaultValue = defaultValue + } - setValue(value) { - switch (this.type) { - case "radio": - Array.prototype.forEach.call(document.getElementsByName(this.name), (el) => { - el.checked = el.value === value; - }); - break; - case "checkbox": - document.getElementsByName(this.name).forEach(el => el.checked = value); - break; - case "select": - document.getElementsByName(this.name).forEach(el => el.value = value); - break; - case "text": - document.getElementsByName(this.name).forEach(el => el.value = value); - break; - } + setValue(value) { + switch (this.type) { + case "radio": + Array.prototype.forEach.call(document.getElementsByName(this.name), (el) => { + el.checked = el.value === value + }) + break + case "checkbox": + document.getElementsByName(this.name).forEach(el => el.checked = value) + break + case "select": + document.getElementsByName(this.name).forEach(el => el.value = value) + break + case "text": + document.getElementsByName(this.name).forEach(el => el.value = value) + break } + } - getValue() { - switch (this.type) { - case "radio": - return document.querySelector(`input[name="${this.name}"]:checked`).value; - case "checkbox": - return document.querySelector(`input[name="${this.name}"]`).checked; - case "select": - return document.querySelector(`select[name="${this.name}"]`).value; - case "text": - return document.querySelector(`input[name="${this.name}"]`).value; - } + getValue() { + switch (this.type) { + case "radio": + return document.querySelector(`input[name="${this.name}"]:checked`).value + case "checkbox": + return document.querySelector(`input[name="${this.name}"]`).checked + case "select": + return document.querySelector(`select[name="${this.name}"]`).value + case "text": + return document.querySelector(`input[name="${this.name}"]`).value } + } } const _options = [ - new Option("iconAction", "radio", "clickOnly"), - new Option("iconActionOption", "radio", "direct"), - new Option("mpvPlayer", "select", "mpv"), - new Option("useCustomFlags", "checkbox", false), - new Option("customFlags", "text", "") -]; + new Option("iconAction", "radio", "clickOnly"), + new Option("iconActionOption", "radio", "direct"), + new Option("mpvPlayer", "select", "mpv"), + new Option("useCustomFlags", "checkbox", false), + new Option("customFlags", "text", "") +] export function getOptions(callback) { - const getDict = {}; - _options.forEach((item) => { - getDict[item.name] = item.defaultValue; - }) - chrome.storage.sync.get(getDict, callback); + const getDict = {} + _options.forEach(item => { + getDict[item.name] = item.defaultValue + }) + chrome.storage.sync.get(getDict, callback) } export function saveOptions() { - const saveDict = {}; - _options.forEach((item) => { - saveDict[item.name] = item.getValue(); - }) - chrome.storage.sync.set(saveDict); + const saveDict = {} + _options.forEach(item => { + saveDict[item.name] = item.getValue() + }) + chrome.storage.sync.set(saveDict) } export function restoreOptions() { - getOptions((items) => { - _options.forEach((option) => { - option.setValue(items[option.name]); - }); - }); + getOptions((items) => { + _options.forEach(option => { + option.setValue(items[option.name]) + }) + }) } export function openInMPV(tabId, url, options = {}) { - const baseURL = `mpv:///open?`; + const baseURL = `mpv:///open?` - // Encode video URL - const params = [`url=${encodeURIComponent(url)}`]; + // Encode video URL + const params = [`url=${encodeURIComponent(url)}`] - // Add playback options - switch (options.mode) { - case "fullScreen": - params.push("full_screen=1"); break; - case "pip": - params.push("pip=1"); break; - case "enqueue": - params.push("enqueue=1"); break; - } + // Add playback options + switch (options.mode) { + case "fullScreen": + params.push("full_screen=1"); break + case "pip": + params.push("pip=1"); break + case "enqueue": + params.push("enqueue=1"); break + } - // Add new window option - if (options.newWindow) { - params.push("new_window=1"); - } + // Add new window option + if (options.newWindow) { + params.push("new_window=1") + } - // Add alternative player and user-defined custom flags - params.push(`player=${options.mpvPlayer}`); - if (options.useCustomFlags && options.customFlags !== "") - params.push(`flags=${encodeURIComponent(options.customFlags)}`); + // Add alternative player and user-defined custom flags + params.push(`player=${options.mpvPlayer}`) + if (options.useCustomFlags && options.customFlags !== "") + params.push(`flags=${encodeURIComponent(options.customFlags)}`) - const code = ` - var link = document.createElement('a'); - link.href='${baseURL}${params.join("&")}'; - document.body.appendChild(link); - link.click(); - `; - console.log(code); - chrome.tabs.executeScript(tabId, { code }); + const code = ` + var link = document.createElement('a') + link.href='${baseURL}${params.join("&")}' + document.body.appendChild(link) + link.click()` + console.log(code) + chrome.tabs.executeScript(tabId, { code }) } export function updateBrowserAction() { - getOptions((options) => { - if (options.iconAction === "clickOnly") { - chrome.browserAction.setPopup({ popup: "" }); - chrome.browserAction.onClicked.addListener(() => { - // get active window - chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => { - if (tabs.length === 0) { return; } - // TODO: filter url - const tab = tabs[0]; - if (tab.id === chrome.tabs.TAB_ID_NONE) { return; } - openInMPV(tab.id, tab.url, { - mode: options.iconActionOption, - ...options, - }); - }); - }); - } else { - chrome.browserAction.setPopup({ popup: "popup.html" }); - } - }); + getOptions(options => { + if (options.iconAction === "clickOnly") { + chrome.browserAction.setPopup({ popup: "" }) + chrome.browserAction.onClicked.addListener(() => { + // Get active tab + chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => { + if (tabs.length === 0) + return + + // TODO: filter url + const tab = tabs[0] + if (tab.id === chrome.tabs.TAB_ID_NONE) + return + + openInMPV(tab.id, tab.url, { + mode: options.iconActionOption, + ...options, + }) + }) + }) + + return + } + + chrome.browserAction.setPopup({ popup: "popup.html" }) + }) } diff --git a/extension/Chrome/options.css b/extension/Chrome/options.css index e9ed6f2..d7ee531 100644 --- a/extension/Chrome/options.css +++ b/extension/Chrome/options.css @@ -1,68 +1,68 @@ body { - font-family: 'Inter', Arial, sans-serif; - font-size: 14px; - line-height: 1.4; + font-family: 'Inter', Arial, sans-serif; + font-size: 14px; + line-height: 1.4; } .container { - width: 90%; - max-width: 880px; - margin: 1rem auto; - background: #f4f4f4; - border-radius: 6px; - box-shadow: 0 0 16px rgba(0,0,0,.2); + width: 90%; + max-width: 880px; + margin: 1rem auto; + background: #f4f4f4; + border-radius: 6px; + box-shadow: 0 0 16px rgba(0, 0, 0, .2); } .title { - padding: 1rem; - margin: 0; + padding: 1rem; + margin: 0; } .item:not(:last-child) { - margin-bottom: 0.5rem; + margin-bottom: 0.5rem; } .option { - padding: 1rem; - border-top: 1px solid #e4e4e4; + padding: 1rem; + border-top: 1px solid #e4e4e4; } .option:not(:last-child) { - border-bottom: 1px solid #e4e4e4; + border-bottom: 1px solid #e4e4e4; } .option .option-title { - font-weight: bold; + font-weight: bold; } .option .option-details { - padding-left: 0.5rem; + padding-left: 0.5rem; } .option .option-details:not(:first-child) { - margin-top: 0.5rem; + margin-top: 0.5rem; } .option .option-details:not(:last-child) { - margin-bottom: 1rem; + margin-bottom: 1rem; } -.option-details > p { - color: rgb(70, 70, 70); - margin-block: 0; +.option-details>p { + color: rgb(70, 70, 70); + margin-block: 0; } input[type="text"] { - font-family: monospace; - border: 1px solid rgb(169, 169, 169); - margin: 0 0 0.5rem 0; - padding: 0.2rem; - border-radius: 6px; + font-family: monospace; + border: 1px solid rgb(169, 169, 169); + margin: 0 0 0.5rem 0; + padding: 0.2rem; + border-radius: 6px; } select { - margin: 0.1rem; - padding: 0.2rem; - border-radius: 6px; - background-color: #fff; + margin: 0.1rem; + padding: 0.2rem; + border-radius: 6px; + background-color: #fff; } \ No newline at end of file diff --git a/extension/Chrome/options.html b/extension/Chrome/options.html index 260b94b..eff8475 100644 --- a/extension/Chrome/options.html +++ b/extension/Chrome/options.html @@ -1,61 +1,66 @@ + - - - - Options - - + + + + Options + + + -
-

Player options

-
- -
- -
-
- -
- -

Note: do include hyphens (e.g.'--fs')

-
-
+
+

Player options

+
+ +
+
-
-

When clicking on the extension icon

-
-
- -
-
- -
-
+
+ +
+ +

Note: do include hyphens (e.g.'--fs')

+
-
-

Default open action

-
-

Applies to "Open current page in mpv" and the right click context menu

-
- -
-
- -
-
- -
-
- -
-
+
+
+

When clicking on the extension icon

+
+
+ +
+
+ +
- +
+
+

Default open action

+
+
+

Applies to "Open current page in mpv" and the right click context menu

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ - + + \ No newline at end of file diff --git a/extension/Chrome/options.js b/extension/Chrome/options.js index 3e98780..914da8f 100644 --- a/extension/Chrome/options.js +++ b/extension/Chrome/options.js @@ -1,13 +1,11 @@ -import { restoreOptions, saveOptions, updateBrowserAction } from "./common.js"; +import { restoreOptions, saveOptions, updateBrowserAction } from "./common.js" -function listener(el) { - el.addEventListener("change", () => { - saveOptions(); - updateBrowserAction(); - }); -} +const addListener = el => el.addEventListener("change", () => { + saveOptions() + updateBrowserAction() +}) -document.addEventListener("DOMContentLoaded", restoreOptions); +document.addEventListener("DOMContentLoaded", restoreOptions) -Array.prototype.forEach.call(document.getElementsByTagName("input"), listener); -Array.prototype.forEach.call(document.getElementsByTagName("select"), listener); +Array.prototype.forEach.call(document.getElementsByTagName("input"), addListener) +Array.prototype.forEach.call(document.getElementsByTagName("select"), addListener) diff --git a/extension/Chrome/popup.html b/extension/Chrome/popup.html index 71f6a9c..28ba7eb 100644 --- a/extension/Chrome/popup.html +++ b/extension/Chrome/popup.html @@ -1,34 +1,39 @@ + - - - - Document - + + + + Document + + - - + + - + + \ No newline at end of file diff --git a/extension/Chrome/popup.js b/extension/Chrome/popup.js index 38e8cd7..8b3f57d 100644 --- a/extension/Chrome/popup.js +++ b/extension/Chrome/popup.js @@ -1,20 +1,24 @@ -import { openInMPV, getOptions } from "./common.js"; +import { openInMPV, getOptions } from "./common.js" -Array.prototype.forEach.call(document.getElementsByClassName("menu-item"), (item) => { - const mode = item.id.split("-")[1]; - item.addEventListener("click", () => { - getOptions((options) => { - chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => { - if (tabs.length === 0) { return; } - const tab = tabs[0]; - if (tab.id === chrome.tabs.TAB_ID_NONE) { return; } - console.log(mode) - openInMPV(tab.id, tab.url, { - mode, - newWindow: mode === "newWindow", - ...options, - }); - }); - }); - }); -}); +Array.prototype.forEach.call(document.getElementsByClassName("menu-item"), item => { + const mode = item.id.split("-")[1] + item.addEventListener("click", () => { + getOptions(options => { + chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => { + if (tabs.length === 0) + return + + const tab = tabs[0] + if (tab.id === chrome.tabs.TAB_ID_NONE) + return + + console.log(mode) + openInMPV(tab.id, tab.url, { + mode, + newWindow: mode === "newWindow", + ...options, + }) + }) + }) + }) +}) diff --git a/go.mod b/go.mod index afadc10..9eae2db 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/Baldomo/open-in-mpv -go 1.15 +go 1.18 require ( github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0 diff --git a/options.go b/options.go index dfdd70f..2e986e0 100644 --- a/options.go +++ b/options.go @@ -11,14 +11,14 @@ import ( // URL and acts as a command generator (both CLI and IPC) to spawn and // communicate with an mpv player window. type Options struct { - Flags string - Player string - Url string Enqueue bool + Flags string Fullscreen bool + NeedsIpc bool NewWindow bool Pip bool - NeedsIpc bool + Player string + Url *url.URL } // Utility object to marshal an mpv-compatible JSON command. As defined in the @@ -30,14 +30,14 @@ type enqueueCmd struct { // Default constructor for an Option object func NewOptions() Options { return Options{ - Flags: "", - Player: "mpv", - Url: "", Enqueue: false, + Flags: "", Fullscreen: false, + NeedsIpc: false, NewWindow: false, Pip: false, - NeedsIpc: false, + Player: "mpv", + Url: nil, } } @@ -60,29 +60,46 @@ func (o *Options) Parse(uri string) error { return fmt.Errorf("Empty or malformed query: %s", u.RawQuery) } + playerConfig := GetPlayerConfig(o.Player) + if playerConfig == nil { + return fmt.Errorf("Unsupported player: %s", o.Player) + } + + // Extract player command line flags o.Flags, err = url.QueryUnescape(u.Query().Get("flags")) if err != nil { return err } - o.Url, err = url.QueryUnescape(u.Query().Get("url")) + + // Extract video file URL + rawUrl, err := url.QueryUnescape(u.Query().Get("url")) if err != nil { return err } + // Parse the unprocessed URL + o.Url, err = url.Parse(rawUrl) + if err != nil { + return err + } + // Validate the raw URL scheme against the configured ones + if !stringSliceContains(o.Url.Scheme, playerConfig.SupportedSchemes) { + return fmt.Errorf( + "Unsupported schema for player '%s': %s. Did you forget to add it in the configuration?", + playerConfig.Name, + o.Url.Scheme, + ) + } if p, ok := u.Query()["player"]; ok { o.Player = p[0] } - if GetPlayerInfo(o.Player) == nil { - return fmt.Errorf("Unsupported player: %s", o.Player) - } - o.Enqueue = u.Query().Get("enqueue") == "1" o.Fullscreen = u.Query().Get("fullscreen") == "1" o.NewWindow = u.Query().Get("new_window") == "1" o.Pip = u.Query().Get("pip") == "1" - o.NeedsIpc = GetPlayerInfo(o.Player).NeedsIpc + o.NeedsIpc = playerConfig.NeedsIpc return nil } @@ -94,52 +111,66 @@ func (o Options) overrideFlags() string { star bool ) - pInfo := GetPlayerInfo(o.Player) - if pInfo == nil { + playerConfig := GetPlayerConfig(o.Player) + if playerConfig == nil { return "" } - _, star = pInfo.FlagOverrides["*"] + // Premature look for star override in configuration + _, star = playerConfig.FlagOverrides["*"] for _, flag := range strings.Split(o.Flags, " ") { if star { + // Unconditionally replace all flags with the star template stripped := strings.TrimLeft(flag, "-") - replaced := strings.ReplaceAll(pInfo.FlagOverrides["*"], `%s`, stripped) + replaced := strings.ReplaceAll( + playerConfig.FlagOverrides["*"], + `%s`, + stripped, + ) ret = append(ret, replaced) - } else { - if override, ok := pInfo.FlagOverrides[flag]; ok { - stripped := strings.TrimLeft(flag, "-") - ret = append(ret, strings.ReplaceAll(override, `%s`, stripped)) - } + continue + } + + // Otherwise, iterate over all templates for the current flag and + // do the necessary string replacements + if override, ok := playerConfig.FlagOverrides[flag]; ok { + stripped := strings.TrimLeft(flag, "-") + ret = append(ret, strings.ReplaceAll( + override, + `%s`, + stripped, + )) } } return strings.Join(ret, " ") } -// Builds a CLI command used to invoke the player with the appropriate arguments +// Builds a CLI command used to invoke the player with the appropriate +// arguments func (o Options) GenerateCommand() []string { var ret []string - pInfo := GetPlayerInfo(o.Player) + playerConfig := GetPlayerConfig(o.Player) if o.Fullscreen { - ret = append(ret, pInfo.Fullscreen) + ret = append(ret, playerConfig.Fullscreen) } if o.Pip { - ret = append(ret, pInfo.Pip) + ret = append(ret, playerConfig.Pip) } if o.Flags != "" { - if len(pInfo.FlagOverrides) == 0 { + if len(playerConfig.FlagOverrides) == 0 { ret = append(ret, o.Flags) } else { ret = append(ret, o.overrideFlags()) } } - ret = append(ret, o.Url) + ret = append(ret, o.Url.String()) return ret } @@ -151,7 +182,7 @@ func (o Options) GenerateIPC() ([]byte, error) { } cmd := enqueueCmd{ - []string{"loadfile", o.Url, "append-play"}, + []string{"loadfile", o.Url.String(), "append-play"}, } ret, err := json.Marshal(cmd) @@ -165,3 +196,14 @@ func (o Options) GenerateIPC() ([]byte, error) { return ret, nil } + +// Simple linear search for value in slice of strings +func stringSliceContains(value string, v []string) bool { + for _, elem := range v { + if elem == value { + return true + } + } + + return false +} diff --git a/options_test.go b/options_test.go index bce82df..ad4f32e 100644 --- a/options_test.go +++ b/options_test.go @@ -1,19 +1,21 @@ package open_in_mpv import ( + "net/url" "strings" "testing" ) var fakePlayer = Player{ - Name: "FakePlayer", - Executable: "fakeplayer", - Fullscreen: "", - Pip: "", - Enqueue: "", - NewWindow: "", - NeedsIpc: true, - FlagOverrides: map[string]string{}, + Name: "FakePlayer", + Executable: "fakeplayer", + Fullscreen: "", + Pip: "", + Enqueue: "", + NewWindow: "", + NeedsIpc: true, + SupportedSchemes: []string{"https"}, + FlagOverrides: map[string]string{}, } func testUrl(query ...string) string { @@ -25,7 +27,7 @@ func testUrl(query ...string) string { func Test_GenerateCommand(t *testing.T) { o := NewOptions() - o.Url = "example.com" + o.Url, _ = url.Parse("example.com") o.Flags = "--vo=gpu" o.Pip = true @@ -79,8 +81,42 @@ func Test_overrideFlags_star(t *testing.T) { } func Test_Parse(t *testing.T) { + fakePlayer.FlagOverrides["*"] = "--bar=%s" + defaultConfig.Players["fakeplayer"] = fakePlayer + o := NewOptions() - _ = o.Parse(testUrl("enqueue=1", "pip=1")) + err := o.Parse(testUrl("player=fakeplayer", "enqueue=1", "pip=1")) + if err != nil { + t.Fatal(err) + } + + fakePlayer.SupportedSchemes = []string{} + defaultConfig.Players["fakeplayer"] = fakePlayer + err = o.Parse(testUrl("player=fakeplayer", "enqueue=1", "pip=1")) + if err == nil { + t.Logf("%#v", defaultConfig.Players["fakeplayer"]) + t.Fatal("Err should not be nil") + } + args := o.GenerateCommand() t.Logf("%s %v", o.Player, args) } + +func Test_sliceContains(t *testing.T) { + schemas := []string{ + "http", + "https", + "ftp", + "ftps", + } + + if !stringSliceContains("https", schemas) { + t.Logf("should return true if element (https) is in slice (%v)", schemas) + t.Fail() + } + + if stringSliceContains("av", schemas) { + t.Logf("should return false if element (av) is not in slice (%v)", schemas) + t.Fail() + } +}