diff --git a/.clang-format b/.clang-format deleted file mode 100644 index 6d77dfa..0000000 --- a/.clang-format +++ /dev/null @@ -1,144 +0,0 @@ ---- -Language: Cpp -# BasedOnStyle: LLVM -AccessModifierOffset: -2 -AlignAfterOpenBracket: AlwaysBreak -AlignConsecutiveMacros: true -AlignConsecutiveAssignments: false -AlignConsecutiveBitFields: false -AlignConsecutiveDeclarations: false -AlignEscapedNewlines: Left -AlignOperands: Align -AlignTrailingComments: true -AllowAllArgumentsOnNextLine: true -AllowAllConstructorInitializersOnNextLine: true -AllowAllParametersOfDeclarationOnNextLine: true -AllowShortEnumsOnASingleLine: true -AllowShortBlocksOnASingleLine: Never -AllowShortCaseLabelsOnASingleLine: false -AllowShortFunctionsOnASingleLine: All -AllowShortLambdasOnASingleLine: All -AllowShortIfStatementsOnASingleLine: Never -AllowShortLoopsOnASingleLine: false -AlwaysBreakAfterDefinitionReturnType: None -AlwaysBreakAfterReturnType: None -AlwaysBreakBeforeMultilineStrings: false -AlwaysBreakTemplateDeclarations: MultiLine -BinPackArguments: true -BinPackParameters: true -BraceWrapping: - AfterCaseLabel: false - AfterClass: false - AfterControlStatement: false - AfterEnum: false - AfterFunction: false - AfterNamespace: false - AfterObjCDeclaration: false - AfterStruct: false - AfterUnion: false - AfterExternBlock: false - BeforeCatch: false - BeforeElse: false - BeforeLambdaBody: false - BeforeWhile: false - IndentBraces: false - SplitEmptyFunction: true - SplitEmptyRecord: true - SplitEmptyNamespace: true -BreakBeforeBinaryOperators: None -BreakBeforeBraces: Attach -BreakBeforeInheritanceComma: false -BreakInheritanceList: AfterColon -BreakBeforeTernaryOperators: false -BreakConstructorInitializersBeforeComma: false -BreakConstructorInitializers: AfterColon -BreakAfterJavaFieldAnnotations: false -BreakStringLiterals: true -ColumnLimit: 80 -CommentPragmas: '^ IWYU pragma:' -CompactNamespaces: false -ConstructorInitializerAllOnOneLineOrOnePerLine: false -ConstructorInitializerIndentWidth: 4 -ContinuationIndentWidth: 4 -Cpp11BracedListStyle: false -DeriveLineEnding: true -DerivePointerAlignment: false -DisableFormat: false -ExperimentalAutoDetectBinPacking: false -FixNamespaceComments: true -ForEachMacros: - - foreach - - Q_FOREACH - - BOOST_FOREACH -IncludeBlocks: Regroup -IncludeCategories: - - Regex: '^"(llvm|llvm-c|clang|clang-c)/' - Priority: 2 - SortPriority: 0 - - Regex: '^(<|"(gtest|gmock|isl|json)/)' - Priority: 3 - SortPriority: 0 - - Regex: '.*' - Priority: 1 - SortPriority: 0 -IncludeIsMainRegex: '(Test)?$' -IncludeIsMainSourceRegex: '' -IndentCaseLabels: false -IndentCaseBlocks: false -IndentGotoLabels: true -IndentPPDirectives: None -IndentExternBlock: AfterExternBlock -IndentWidth: 4 -IndentWrappedFunctionNames: false -InsertTrailingCommas: None -JavaScriptQuotes: Leave -JavaScriptWrapImports: true -KeepEmptyLinesAtTheStartOfBlocks: true -MacroBlockBegin: '' -MacroBlockEnd: '' -MaxEmptyLinesToKeep: 1 -NamespaceIndentation: None -PenaltyBreakAssignment: 2 -PenaltyBreakBeforeFirstCallParameter: 19 -PenaltyBreakComment: 300 -PenaltyBreakFirstLessLess: 120 -PenaltyBreakString: 1000 -PenaltyBreakTemplateDeclaration: 10 -PenaltyExcessCharacter: 1000000 -PenaltyReturnTypeOnItsOwnLine: 60 -PointerAlignment: Right -ReflowComments: true -SortIncludes: true -SortUsingDeclarations: true -SpaceAfterCStyleCast: false -SpaceAfterLogicalNot: false -SpaceAfterTemplateKeyword: true -SpaceBeforeAssignmentOperators: true -SpaceBeforeCpp11BracedList: false -SpaceBeforeCtorInitializerColon: true -SpaceBeforeInheritanceColon: true -SpaceBeforeParens: ControlStatements -SpaceBeforeRangeBasedForLoopColon: true -SpaceInEmptyBlock: false -SpaceInEmptyParentheses: false -SpacesBeforeTrailingComments: 1 -SpacesInAngles: false -SpacesInConditionalStatement: false -SpacesInContainerLiterals: true -SpacesInCStyleCastParentheses: false -SpacesInParentheses: false -SpacesInSquareBrackets: false -SpaceBeforeSquareBrackets: false -Standard: Latest -StatementMacros: - - Q_UNUSED - - QT_REQUIRE_VERSION -TabWidth: 8 -UseCRLF: false -UseTab: Never -WhitespaceSensitiveMacros: - - STRINGIZE - - PP_STRINGIZE - - BOOST_PP_STRINGIZE -... - diff --git a/.gitignore b/.gitignore index 678dcbf..d163863 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -*.zip -*.crx +build/ \ No newline at end of file diff --git a/Makefile b/Makefile index f6c677a..030a621 100644 --- a/Makefile +++ b/Makefile @@ -1,35 +1,31 @@ -INCLUDES = -Isrc/ -CXXFLAGS_debug = -Wall -DDEBUG -g -rdynamic -std=c++2a $(INCLUDES) -CXXFLAGS_release = -Wall -fvisibility=hidden -fvisibility-inlines-hidden -std=c++2a -march=x86-64 -mtune=generic -O3 -pipe -fno-plt $(INCLUDES) -SRCS = src/ipc.hpp \ - src/options.hpp \ - src/players.hpp \ - src/url.hpp \ - src/main.cpp +SRC:=config.go ipc.go options.go +EXT_SRC:=$(wildcard extension/Chrome/*) extension/Firefox/manifest.json -all: release firefox +all: build/open-in-mpv -release: $(SRCS) - $(CXX) $(CXXFLAGS_release) -o open-in-mpv src/main.cpp +build/open-in-mpv: $(SRC) + @mkdir -p build + go build -ldflags="-s -w" -o build/open-in-mpv ./cmd/open-in-mpv -debug: $(SRCS) - $(CXX) $(CXXFLAGS_debug) -o open-in-mpv src/main.cpp +build/Firefox.zip: $(EXT_SRC) + @mkdir -p build + cp -t extension/Firefox extension/Chrome/{*.html,*.js,*.png,*.css} + zip -r build/Firefox.zip extension/Firefox/ + @rm extension/Firefox/{*.html,*.js,*.png,*.css} -install: release - cp open-in-mpv /usr/bin +install: build/open-in-mpv + cp build/open-in-mpv /usr/bin + +install-protocol: + scripts/install-protocol.sh uninstall: rm /usr/bin/open-in-mpv -firefox: - cp -t Firefox Chrome/{*.html,*.js,*.png,*.css} - pushd Firefox && zip ../Firefox.zip * && popd - @rm Firefox/{*.html,*.js,*.png,*.css} - clean: - @rm -f open-in-mpv Firefox.zip Chrome.crx + rm -rf build/* -fmt: - clang-format -i src/*.{hpp,cpp} +test: + go test ./... -.PHONY: all release debug install uninstall firefox clean fmt +.PHONY: all install install-protocol uninstall clean test \ No newline at end of file diff --git a/README.md b/README.md index 957e0e3..9d9d5a1 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,20 @@

open-in-mpv

- - + +

--- 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 C++20 (this is a rewrite from Rust). +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++). - [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) @@ -19,10 +22,84 @@ The extension itself shares a lot of code with the one from the awesome [iina](h ### Installation > Compiled binaries and packed extensions can be found in the [releases page](https://github.com/Baldomo/open-in-mpv/releases). -This project does not require any external library to run or compile release builds. To build and install `open-in-mpv`, just run +To build and install `open-in-mpv` and the `mpv://` protocol handler, just run -```sh -sudo make install +``` +$ sudo make install +$ make install-protocol +``` + +### Configuration +The configuration file has to be named `config.yml` and can be placed in the same folder as the executable or in: +- Windows: `C:\Users\\AppData\Roaming\open-in-mpv\` +- Linux: `~/.config/open-in-mpv/` +- Mac: `~/Library/Application Support/open-in-mpv/` + +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 ] +``` + +> See [the default configuration](config.yml) as an example + +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) | + +#### Flag overrides + +The configuration for a player can contain overrides for command line flags defined in the web extension's configuration page, so not to make a different player crash/not start because the flags are not accepted. With the correct overrides any kind of flag passed in [the URL](#the-mpv-protocol) by the extension will either be ignored or replaced with a pattern/prefix/suffix so that it becomes valid for the player in use. + +- `"*"`: matches anything and will take precedence over any other override + - e.g. the pair `"*": ""` will void all flags +- `"flag"`: matches the flag `--flag` + - e.g. the pair `"--foo": "--bar"` will replace `--foo` with `--bar` +- `"%s"`: is replaced with the original flag without any leading dash + - e.g. the pair `"--foo": "--%s-bar"` will replace `--foo` with `--foo-bar` + +> Note: command line options with parameters such as `--foo=bar` are considered as a whole, single flag + +Celluloid, for example, expects all mpv-related command line flags to be prefixed with `--mpv-`, so its configuration will contain the following overrides: + +```yaml +flag_overrides: + "*": "--mpv-%s" +``` + +#### Example + +This is a full example of a fictitious player definition in the configuration file: + +```yaml +players: + fake: + name: fake-player + executable: fakeplayer + fullscreen: "--fs" + pip: "--ontop --no-border --autofit=384x216 --geometry=98\\%:98\\%" + enqueue: "--enqueue" + new_window: "" + needs_ipc: true + flag_overrides: + "*": "--mpv-options=%s" ``` ### The `mpv://` protocol @@ -37,7 +114,7 @@ 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) | @@ -47,11 +124,11 @@ The table below is a simple documentation of the URL query keys and values used | `flags` | `--vo%3Dgpu` | Custom command options and flags to be passed to the video player, URL-encoded | ### Playlist and `enqueue` functionality -For `enqueue` to work properly with any mpv-based player (provided it supports mpv's IPC), the player has to read commands from a socket. This can be achieved by adding the following line to the video player's configuration (usually `.config/mpv/mpv.conf` for mpv). +For `enqueue` to work properly with any mpv-based player (provided it supports mpv's IPC), the player has to read commands from a socket. This can be achieved by adding the following line to the video player's configuration (usually `$HOME/.config/mpv/mpv.conf` for mpv). ```conf input-ipc-server=/tmp/mpvsocket ``` ### Player support -Supported players are defined in `src/players.hpp`, where the struct `player` 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. \ 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. \ No newline at end of file diff --git a/cmd/open-in-mpv/main.go b/cmd/open-in-mpv/main.go new file mode 100644 index 0000000..b76206a --- /dev/null +++ b/cmd/open-in-mpv/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + + oim "github.com/Baldomo/open-in-mpv" +) + +func must(err error) { + if err == nil { + return + } + + log.Fatal(err.Error()) +} + +func main() { + if len(os.Args) < 2 { + fmt.Println("This program is not supposed to be called from the command line!") + os.Exit(1) + } + + must(oim.LoadConfig()) + + opts := oim.NewOptions() + must(opts.Parse(os.Args[1])) + + if opts.NeedsIpc { + cmd, err := opts.GenerateIPC() + must(err) + err = oim.SendBytes(cmd) + if err == nil { + os.Exit(0) + } + log.Println("Error writing to socket, opening new instance") + } + + args := opts.GenerateCommand() + player := exec.Command(opts.Player, args...) + log.Println(player.String()) + must(player.Start()) + // must(player.Wait()) +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..e9ad3ce --- /dev/null +++ b/config.go @@ -0,0 +1,82 @@ +package open_in_mpv + +import ( + "log" + "path/filepath" + "strings" + + "github.com/shibukawa/configdir" + "gopkg.in/yaml.v2" +) + +// The Player struct contains useful informations for an mpv-based player, +// such as binary name and fullscreen/pip/enqueue/new_window flags overrides for +// use in GenerateIPC(). A way to override any generic flags is also +// provided through the map FlagOverrides. +type Player struct { + // Full name of the player + Name string + // Executable path/name (if already in $PATH) for the player + Executable string + // Flag override for fullscreen + Fullscreen string + // Flag override for picture-in-picture mode + Pip string + // Flag override for enqueuing videos + Enqueue string + // Flag override for forcing new window + NewWindow string `yaml:"new_window"` + // Controls whether this player needs IPC command to enqueue videos + NeedsIpc bool `yaml:"needs_ipc"` + // Overrides for any generic flag + FlagOverrides map[string]string `yaml:"flag_overrides"` +} + +// Top-level configuration object, maps a player by name to its Player object +type Config struct { + Players map[string]Player +} + +var defaultConfig = Config{ + Players: map[string]Player{ + "mpv": { + Name: "mpv", + Executable: "mpv", + Fullscreen: "--fs", + Pip: `--ontop --no-border --autofit=384x216 --geometry=98%:98%`, + Enqueue: "", + NewWindow: "", + NeedsIpc: true, + FlagOverrides: map[string]string{}, + }, + }, +} + +// Tries to load configuration file with fallback +func LoadConfig() error { + confDirs := configdir.New("", "open-in-mpv") + confDirs.LocalPath, _ = filepath.Abs(".") + confFolder := confDirs.QueryFolderContainsFile("config.yml") + + if confFolder == nil { + log.Println("No config file found, using default") + return nil + } + + data, err := confFolder.ReadFile("config.yml") + if err != nil { + return err + } + + return yaml.Unmarshal(data, &defaultConfig) +} + +// Returns player information for the given name if present, otherwise nil +func GetPlayerInfo(name string) *Player { + lowerName := strings.ToLower(name) + if p, ok := defaultConfig.Players[lowerName]; ok { + return &p + } + + return nil +} diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..3306361 --- /dev/null +++ b/config.yml @@ -0,0 +1,30 @@ +--- +players: + mpv: + name: mpv + executable: mpv + fullscreen: "--fs" + pip: "--ontop --no-border --autofit=384x216 --geometry=98\\%:98\\%" + enqueue: "" + new_window: "" + needs_ipc: true + flag_overrides: {} + celluloid: + name: Celluloid + executable: celluloid + fullscreen: "" + pip: "" + enqueue: "--enqueue" + new_window: "--new-window" + needs_ipc: false + flag_overrides: + "*": "--mpv-%s" + mpvnet: + name: mpv.net + executable: mpvnet.exe + fullscreen: "--fs" + pip: "--ontop --no-border --autofit=384x216 --geometry=98\\%:98\\%" + enqueue: "--queue" + new_window: "" + needs_ipc: false + flag_overrides: {} \ No newline at end of file diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..33ae5fa --- /dev/null +++ b/config_test.go @@ -0,0 +1,11 @@ +package open_in_mpv + +import "testing" + +func Test_LoadConfig(t *testing.T) { + err := LoadConfig() + if err != nil { + t.Error(err) + } + t.Logf("%#v\n", defaultConfig) +} diff --git a/Chrome/README.md b/extension/Chrome/README.md similarity index 100% rename from Chrome/README.md rename to extension/Chrome/README.md diff --git a/Chrome/background.html b/extension/Chrome/background.html similarity index 100% rename from Chrome/background.html rename to extension/Chrome/background.html diff --git a/Chrome/background.js b/extension/Chrome/background.js similarity index 100% rename from Chrome/background.js rename to extension/Chrome/background.js diff --git a/Chrome/common.js b/extension/Chrome/common.js similarity index 100% rename from Chrome/common.js rename to extension/Chrome/common.js diff --git a/Chrome/icon.png b/extension/Chrome/icon.png similarity index 100% rename from Chrome/icon.png rename to extension/Chrome/icon.png diff --git a/Chrome/icon.svg b/extension/Chrome/icon.svg similarity index 100% rename from Chrome/icon.svg rename to extension/Chrome/icon.svg diff --git a/Chrome/icon128.png b/extension/Chrome/icon128.png similarity index 100% rename from Chrome/icon128.png rename to extension/Chrome/icon128.png diff --git a/Chrome/icon16.png b/extension/Chrome/icon16.png similarity index 100% rename from Chrome/icon16.png rename to extension/Chrome/icon16.png diff --git a/Chrome/icon48.png b/extension/Chrome/icon48.png similarity index 100% rename from Chrome/icon48.png rename to extension/Chrome/icon48.png diff --git a/Chrome/manifest.json b/extension/Chrome/manifest.json similarity index 100% rename from Chrome/manifest.json rename to extension/Chrome/manifest.json diff --git a/Chrome/options.css b/extension/Chrome/options.css similarity index 100% rename from Chrome/options.css rename to extension/Chrome/options.css diff --git a/Chrome/options.html b/extension/Chrome/options.html similarity index 100% rename from Chrome/options.html rename to extension/Chrome/options.html diff --git a/Chrome/options.js b/extension/Chrome/options.js similarity index 100% rename from Chrome/options.js rename to extension/Chrome/options.js diff --git a/Chrome/popup.html b/extension/Chrome/popup.html similarity index 100% rename from Chrome/popup.html rename to extension/Chrome/popup.html diff --git a/Chrome/popup.js b/extension/Chrome/popup.js similarity index 100% rename from Chrome/popup.js rename to extension/Chrome/popup.js diff --git a/Firefox/manifest.json b/extension/Firefox/manifest.json similarity index 100% rename from Firefox/manifest.json rename to extension/Firefox/manifest.json diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..afadc10 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/Baldomo/open-in-mpv + +go 1.15 + +require ( + github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0 + gopkg.in/yaml.v2 v2.4.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6be7e62 --- /dev/null +++ b/go.sum @@ -0,0 +1,5 @@ +github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0 h1:Xuk8ma/ibJ1fOy4Ee11vHhUFHQNpHhrBneOCNHVXS5w= +github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0/go.mod h1:7AwjWCpdPhkSmNAgUv5C7EJ4AbmjEB3r047r3DXWu3Y= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/images/badge-chromium.png b/images/badge-chromium.png new file mode 100644 index 0000000..350835d Binary files /dev/null and b/images/badge-chromium.png differ diff --git a/images/badge-chromium.svg b/images/badge-chromium.svg new file mode 100644 index 0000000..a97ec3a --- /dev/null +++ b/images/badge-chromium.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/badge-firefox.png b/images/badge-firefox.png new file mode 100644 index 0000000..7618372 Binary files /dev/null and b/images/badge-firefox.png differ diff --git a/images/badge-firefox.svg b/images/badge-firefox.svg new file mode 100644 index 0000000..471a0ec --- /dev/null +++ b/images/badge-firefox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ipc.go b/ipc.go new file mode 100644 index 0000000..fba70cd --- /dev/null +++ b/ipc.go @@ -0,0 +1,47 @@ +package open_in_mpv + +import "net" + +const defaultSocket = "/tmp/mpvsocket" + +var defaultIpc = Ipc{ + SocketAddress: defaultSocket, +} + +// Defines an IPC connection with a UNIX socket +type Ipc struct { + conn net.Conn + SocketAddress string +} + +// Send a byte-encoded command to the specified UNIX socket +func (i *Ipc) Send(cmd []byte) error { + var err error + i.conn, err = net.Dial("unix", i.SocketAddress) + if err != nil { + return err + } + defer i.conn.Close() + + // The command has to be newline terminated + if cmd[len(cmd)-1] != '\n' { + cmd = append(cmd, '\n') + } + + _, err = i.conn.Write(cmd) + if err != nil { + return err + } + + return nil +} + +// Generic public send string command for the default connection +func SendString(cmd string) error { + return defaultIpc.Send([]byte(cmd)) +} + +// Generic public send byte-encoded string command for the default connection +func SendBytes(cmd []byte) error { + return defaultIpc.Send(cmd) +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..dfdd70f --- /dev/null +++ b/options.go @@ -0,0 +1,167 @@ +package open_in_mpv + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" +) + +// The Options struct defines a model for the data contained in the mpv:// +// 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 + Fullscreen bool + NewWindow bool + Pip bool + NeedsIpc bool +} + +// Utility object to marshal an mpv-compatible JSON command. As defined in the +// documentation, a valid IPC command is a JSON array of strings +type enqueueCmd struct { + Command []string `json:"command,omitempty"` +} + +// Default constructor for an Option object +func NewOptions() Options { + return Options{ + Flags: "", + Player: "mpv", + Url: "", + Enqueue: false, + Fullscreen: false, + NewWindow: false, + Pip: false, + NeedsIpc: false, + } +} + +// Parse a mpv:// URL and populate the current Options +func (o *Options) Parse(uri string) error { + u, err := url.Parse(uri) + if err != nil { + return err + } + + if u.Scheme != "mpv" { + return fmt.Errorf("Unsupported protocol: %s", u.Scheme) + } + + if u.Path != "/open" { + return fmt.Errorf("Unsupported method: %s", u.Path) + } + + if len(u.RawQuery) < 2 { + return fmt.Errorf("Empty or malformed query: %s", u.RawQuery) + } + + o.Flags, err = url.QueryUnescape(u.Query().Get("flags")) + if err != nil { + return err + } + o.Url, err = url.QueryUnescape(u.Query().Get("url")) + if err != nil { + return err + } + + 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 + + return nil +} + +// Parses flag overrides and returns the final flags +func (o Options) overrideFlags() string { + var ( + ret []string + star bool + ) + + pInfo := GetPlayerInfo(o.Player) + if pInfo == nil { + return "" + } + + _, star = pInfo.FlagOverrides["*"] + + for _, flag := range strings.Split(o.Flags, " ") { + if star { + stripped := strings.TrimLeft(flag, "-") + replaced := strings.ReplaceAll(pInfo.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)) + } + } + } + + return strings.Join(ret, " ") +} + +// 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) + + if o.Fullscreen { + ret = append(ret, pInfo.Fullscreen) + } + + if o.Pip { + ret = append(ret, pInfo.Pip) + } + + if o.Flags != "" { + if len(pInfo.FlagOverrides) == 0 { + ret = append(ret, o.Flags) + } else { + ret = append(ret, o.overrideFlags()) + } + } + + ret = append(ret, o.Url) + + return ret +} + +// Builds the IPC command needed to enqueue videos if the player requires it +func (o Options) GenerateIPC() ([]byte, error) { + if !o.NeedsIpc { + return []byte{}, fmt.Errorf("This player does not need IPC") + } + + cmd := enqueueCmd{ + []string{"loadfile", o.Url, "append-play"}, + } + + ret, err := json.Marshal(cmd) + if err != nil { + return []byte{}, err + } + + if ret[len(ret)-1] != '\n' { + ret = append(ret, '\n') + } + + return ret, nil +} diff --git a/options_test.go b/options_test.go new file mode 100644 index 0000000..bce82df --- /dev/null +++ b/options_test.go @@ -0,0 +1,86 @@ +package open_in_mpv + +import ( + "strings" + "testing" +) + +var fakePlayer = Player{ + Name: "FakePlayer", + Executable: "fakeplayer", + Fullscreen: "", + Pip: "", + Enqueue: "", + NewWindow: "", + NeedsIpc: true, + FlagOverrides: map[string]string{}, +} + +func testUrl(query ...string) string { + elems := []string{ + `mpv:///open?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ`, + } + return strings.Join(append(elems, query...), "&") +} + +func Test_GenerateCommand(t *testing.T) { + o := NewOptions() + o.Url = "example.com" + o.Flags = "--vo=gpu" + o.Pip = true + + args := o.GenerateCommand() + t.Logf("%s %v", o.Player, args) +} + +func Test_GenerateIPC(t *testing.T) { + o := NewOptions() + _ = o.Parse(testUrl("enqueue=1", "pip=1")) + ipc, _ := o.GenerateIPC() + t.Log(string(ipc)) +} + +func Test_overrideFlags_single(t *testing.T) { + fakePlayer.FlagOverrides["--foo"] = "--bar=%s" + defaultConfig.Players["fakeplayer"] = fakePlayer + + o := NewOptions() + err := o.Parse(testUrl("player=fakeplayer", "flags=--foo")) + if err != nil { + t.Error(err) + } + + result := o.overrideFlags() + + if result != "--bar=foo" { + t.Fail() + t.Log(result) + t.Logf("%#v\n", o) + } +} + +func Test_overrideFlags_star(t *testing.T) { + fakePlayer.FlagOverrides["*"] = "--bar=%s" + defaultConfig.Players["fakeplayer"] = fakePlayer + + o := NewOptions() + err := o.Parse(testUrl("player=fakeplayer", "flags=--foo%20--baz")) + if err != nil { + t.Error(err) + } + + result := o.overrideFlags() + + if result != "--bar=foo --bar=baz" { + t.Fail() + t.Log(result) + t.Logf("%#v\n", o) + } +} + +func Test_Parse(t *testing.T) { + o := NewOptions() + _ = o.Parse(testUrl("enqueue=1", "pip=1")) + args := o.GenerateCommand() + t.Logf("%s %v", o.Player, args) +} diff --git a/scripts/install-protocol.reg b/scripts/install-protocol.reg new file mode 100644 index 0000000..e2667ff --- /dev/null +++ b/scripts/install-protocol.reg @@ -0,0 +1,12 @@ +Windows Registry Editor Version 5.00 + +[HKEY_CLASSES_ROOT\mpv] +"URL Protocol"="" +@="URL:mpv" + +[HKEY_CLASSES_ROOT\mpv\shell] + +[HKEY_CLASSES_ROOT\mpv\shell\open] + +[HKEY_CLASSES_ROOT\mpv\shell\open\command] +@="\"C:\\Program Files\\open-in-mpv\\open-in-mpv.exe\" \"%1\"" diff --git a/install-protocol.sh b/scripts/install-protocol.sh similarity index 100% rename from install-protocol.sh rename to scripts/install-protocol.sh diff --git a/src/ipc.hpp b/src/ipc.hpp deleted file mode 100644 index 2fc233f..0000000 --- a/src/ipc.hpp +++ /dev/null @@ -1,58 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -using std::string; - -const char *DEFAULT_SOCK = "/tmp/mpvsocket"; - -namespace oim { - -/* - * The class oim::ipc provides easy communication and basic socket management - * for any running mpv instance configured to receive commands over a JSON-IPC - * server/socket. - */ -class ipc { - private: - sockaddr_un sockaddress_; - int sockfd_; - int socklen_; - - public: - ipc() : ipc(DEFAULT_SOCK){}; - - /* - * Constructor for oim::ipc - */ - ipc(const char *sockpath); - - /* - * Destructor for oim::ipc - */ - ~ipc() { close(sockfd_); }; - - /* - * Sends a raw command string to the internal socket at DEFAULT_SOCK - */ - bool send(string cmd); -}; - -ipc::ipc(const char *sockpath) { - sockfd_ = socket(AF_UNIX, SOCK_STREAM, 0); - sockaddress_.sun_family = AF_UNIX; - std::strcpy(sockaddress_.sun_path, sockpath); - socklen_ = sizeof(sockaddress_); - - connect(sockfd_, (const sockaddr *)&sockaddress_, socklen_); -} - -bool ipc::send(string cmd) { - return write(sockfd_, cmd.c_str(), cmd.length()) != -1; -} - -} // namespace oim \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp deleted file mode 100644 index 955796c..0000000 --- a/src/main.cpp +++ /dev/null @@ -1,70 +0,0 @@ -#include "ipc.hpp" -#include "options.hpp" - -#include -#include -#include -#include - -using std::string; - -const char *help[2] = { - "This program is not supposed to be called from the command line!", - "Call with 'install-protocol' to instal the xdg-compatible protocol file " - "in ~/.local/share/applications/" -}; - -bool install_protocol() { - const char *protocol_file = R"([Desktop Entry] -Name=open-in-mpv -Exec=open-in-mpv %u -Type=Application -Terminal=false -NoDisplay=true -MimeType=x-scheme-handler/mpv -)"; - - const char *homedir = std::getenv("HOME"); - if (!homedir) - return false; - - std::ofstream protfile( - string(homedir) + "/.local/share/applications/open-in-mpv.desktop"); - protfile << protocol_file; - protfile.flush(); - protfile.close(); - - return true; -} - -int main(int argc, char const *argv[]) { - if (argc == 1) { - std::cout << help[0] << std::endl << help[1] << std::endl; - return 0; - }; - - if (string(argv[1]) == "install-protocol") - return install_protocol() ? 0 : 1; - - oim::options *mo = new oim::options(); - try { - mo->parse(argv[1]); - } catch (string err) { - std::cout << err << std::endl; - return 1; - } - - if (mo->needs_ipc()) { - oim::ipc *mipc = new oim::ipc(); - bool success = mipc->send(mo->build_ipc()); - if (success) { - return 0; - } - - std::cerr << "Error writing to socket, opening new instance" - << std::endl; - } - std::system(mo->build_cmd().c_str()); - - return 0; -} diff --git a/src/options.hpp b/src/options.hpp deleted file mode 100644 index d71093a..0000000 --- a/src/options.hpp +++ /dev/null @@ -1,197 +0,0 @@ -#pragma once - -#include "players.hpp" -#include "url.hpp" - -#include -#include -#include -#include -#include -#include - -using std::string; - -namespace { -// Replace all occurrences of `from` with `to` in `str` -string replace_all(string str, const string &from, const string &to) { - size_t start_pos = 0; - while ((start_pos = str.find(from, start_pos)) != string::npos) { - str.replace(start_pos, from.length(), to); - // Handles case where 'to' is a substring of 'from' - start_pos += to.length(); - } - return str; -} - -// Remove all characters equals to `c` at the beginning of the string -string lstrip(string str, char c) { - while (str.at(0) == c) - str.replace(0, 1, ""); - return str; -} -} // namespace - -namespace oim { - -/* - * The class oim::options defines a model for the data contained in the mpv:// - * URL and acts as a command generator (both CLI and IPC) to spawn and - * communicate with an mpv player window. - */ -class options { - private: - player *player_info_; - string url_; - string flags_; - string player_; - bool fullscreen_; - bool pip_; - bool enqueue_; - bool new_window_; - - /* - * Parses flag overrides and returns the final flags - */ - string override_flags(); - - public: - /* - * Constructor for oim::options - */ - options(); - - /* - * Builds a CLI command used to invoke mpv with the appropriate arguments - */ - string build_cmd(); - - /* - * Builds the IPC command needed to enqueue videos in mpv - */ - string build_ipc(); - - /* - * Parse a URL and populate the current oim::options - */ - void parse(const char *url); - - /* - * Checks wether or not oim::options needs to communicate with mpv via IPC - * instead of the command line interface - */ - bool needs_ipc(); -}; - -options::options() { - url_ = ""; - flags_ = ""; - player_ = "mpv"; - fullscreen_ = false; - pip_ = false; - enqueue_ = false; - new_window_ = false; -} - -string options::build_cmd() { - std::ostringstream ret; - - if (player_info_ == nullptr) { - return ""; - } - - ret << player_info_->executable << " "; - if (fullscreen_ && !player_info_->fullscreen.empty()) - ret << player_info_->fullscreen << " "; - if (pip_ && !player_info_->pip.empty()) - ret << player_info_->pip << " "; - if (!flags_.empty()) { - if (!player_info_->flag_overrides.empty()) - ret << override_flags() << " "; - else - ret << flags_ << " "; - } - ret << url_; - - return ret.str(); -} - -string options::build_ipc() { - std::ostringstream ret; - - if (!needs_ipc()) - return ""; - - // In the future this may need a more serious json serializer for - // more complicated commands - // Syntax: {"command": ["loadfile", "%s", "append-play"]}\n - ret << R"({"command": ["loadfile", ")" << url_ << R"(", "append-play"]})" - << std::endl; - - return ret.str(); -} - -string options::override_flags() { - // Return immediatly in case there are no overrides - if (player_info_->flag_overrides.empty()) - return flags_; - - bool star = false; - std::ostringstream ret; - - // Check whether there's a global override - auto star_pair = player_info_->flag_overrides.find("*"); - if (star_pair != player_info_->flag_overrides.end()) - star = true; - - // Turn flags_ into a stream to tokenize somewhat idiomatically - auto flagstream = std::istringstream{ flags_ }; - string tmp; - - while (flagstream >> tmp) { - if (star) { - // Remove all dashes at the beginning of the flag - string stripped = ::lstrip(tmp, '-'); - ret << ::replace_all((*star_pair).second, "%s", stripped); - } else { - // Search for the flag currently being processed - auto fo = player_info_->flag_overrides.find(tmp); - if (fo == player_info_->flag_overrides.end()) - continue; - - ret << ::replace_all((*fo).second, "%s", tmp); - } - } - - return ret.str(); -} - -void options::parse(const char *url_s) { - oim::url u(url_s); - - if (u.protocol() != "mpv") - throw string("Unsupported protocol supplied: ") + u.protocol(); - - if (u.path() != "/open") - throw string("Unsupported method supplied: ") + u.path(); - - if (u.query().empty()) - throw string("Empty query"); - - url_ = percent_decode(u.query_value("url")); - flags_ = percent_decode(u.query_value("flags")); - player_ = u.query_value("player", "mpv"); - - player_info_ = get_player_info(player_); - if (player_info_ == nullptr) - throw string("Unsupported player: ") + player_; - - fullscreen_ = u.query_value("full_screen") == "1"; - pip_ = u.query_value("pip") == "1"; - enqueue_ = u.query_value("enqueue") == "1"; - new_window_ = u.query_value("new_window") == "1"; -} - -bool options::needs_ipc() { return player_info_->needs_ipc; } - -} // namespace oim \ No newline at end of file diff --git a/src/players.hpp b/src/players.hpp deleted file mode 100644 index 24409f7..0000000 --- a/src/players.hpp +++ /dev/null @@ -1,85 +0,0 @@ -#pragma once - -#include -#include -#include - -using std::string; -using std::unordered_map; - -namespace oim { - -/* - * Struct `oim::player` contains useful informations for an mpv-based player, - * such as binary name and fullscreen/pip/enqueue/new_window flags overrides for - * use in `options::build_ipc()`. A way to override any generic flags is also - * provided through the map `flag_overrides`. - */ -struct player { - string name; - string executable; - - string fullscreen; - string pip; - string enqueue; - string new_window; - - bool needs_ipc; - - /* - * Overrides for any extra command line flag - * - * Override syntax: - * `"*"`: matches anything and will take precedence over any other - * override - * e.g. the pair `{"*", ""}` will void all flags - * `"flag"`: matches the flag `--flag` - * e.g. the pair `{"--foo", "--bar"}` will replace `--foo` with - * `--bar` - * `"%s"`: is replaced with the original flag without the leading `--` - * e.g. the pair `{"--foo", "--%s-bar"}` will replace `--foo` with - * `--foo-bar` - * - * Note: command line options with parameters such as `--foo=bar` are - * considered a flags as a whole - */ - unordered_map flag_overrides; -}; - -unordered_map player_info = { - { "mpv", - { .name = "mpv", - .executable = "mpv", - .fullscreen = "--fs", - .pip = "--ontop --no-border --autofit=384x216 --geometry=98\%:98\%", - .enqueue = "", - .new_window = "", - .needs_ipc = true, - .flag_overrides = {} } }, - { "celluloid", - { .name = "Celluloid", - .executable = "celluloid", - .fullscreen = "", - .pip = "", - .enqueue = "--enqueue", - .new_window = "--new-window", - .needs_ipc = false, - .flag_overrides = { { "*", "--mpv-options=%s" } } } }, -}; - -player *get_player_info(string name) { - if (name.empty()) - return &player_info["mpv"]; - - string lower_name(name); - std::transform(name.begin(), name.end(), lower_name.begin(), ::tolower); - - auto info = player_info.find(lower_name); - if (info == player_info.end()) { - return nullptr; - } - - return &(*info).second; -} - -} // namespace oim diff --git a/src/url.hpp b/src/url.hpp deleted file mode 100644 index bc89ca2..0000000 --- a/src/url.hpp +++ /dev/null @@ -1,160 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -using std::string; - -namespace { -/* - * Converts a single character to a percent-decodable byte representation - * Taken from - * https://github.com/cpp-netlib/url/blob/main/include/skyr/v1/percent_encoding/percent_decode_range.hpp - */ -inline std::byte alnum_to_hex(char value) { - if ((value >= '0') && (value <= '9')) { - return static_cast(value - '0'); - } - - if ((value >= 'a') && (value <= 'f')) { - return static_cast(value + '\x0a' - 'a'); - } - - if ((value >= 'A') && (value <= 'F')) { - return static_cast(value + '\x0a' - 'A'); - } - - return static_cast(' '); -} - -} // namespace - -namespace oim { - -/* - * The class oim::url contains utility methods to parse a URL string and - * access its fields by name (e.g. parses protocol, host, query etc.). Simple - * query value searching by key is also provided by url::query_value(string). - */ -class url { - private: - string protocol_, host_, path_, query_; - - public: - /* Constructor with C-style string URL */ - url(const char *url_s) : url(string(url_s)){}; - - /* Constructor with C++ std::string URL */ - url(const string &url_s); - - /* Move constructor for oim::url */ - url(url &&other); - - /* Accessor for the URL's protocol string */ - string protocol() { return protocol_; } - - /* Accessor for the URL's host string */ - string host() { return host_; } - - /* Accessor for the URL's whole path */ - string path() { return path_; } - - /* Accessor for the URL's whole query string */ - string query() { return query_; } - - /* - * Gets a value from a query string given a key - */ - string query_value(string key); - - /* - * Gets a value from a query string given a key (overload with optional - * fallback if value isn't found) - */ - string query_value(string key, string fallback); -}; - -url::url(const string &url_s) { - const string prot_end("://"); - string::const_iterator prot_i = std::search( - url_s.begin(), url_s.end(), prot_end.begin(), prot_end.end()); - protocol_.reserve(std::distance(url_s.begin(), prot_i)); - // The protocol is case insensitive - std::transform( - url_s.begin(), prot_i, std::back_inserter(protocol_), ::tolower); - if (prot_i == url_s.end()) - return; - std::advance(prot_i, prot_end.length()); - - // The path starts with '/' - string::const_iterator path_i = std::find(prot_i, url_s.end(), '/'); - host_.reserve(std::distance(prot_i, path_i)); - // The host is also case insensitive - std::transform(prot_i, path_i, std::back_inserter(host_), ::tolower); - - // Everything else is query - string::const_iterator query_i = std::find(path_i, url_s.end(), '?'); - path_.assign(path_i, query_i); - if (query_i != url_s.end()) - ++query_i; - query_.assign(query_i, url_s.end()); -} - -url::url(url &&other) { - protocol_ = std::move(other.protocol_); - host_ = std::move(other.host_); - path_ = std::move(other.path_); - query_ = std::move(other.query_); -} - -string url::query_value(string key) { - // Find the beginning of the last occurrence of `key` in `query` - auto pos = query_.rfind(key + "="); - if (pos == string::npos) - return ""; - - // Offset calculation (beginning of the value string associated with - // `key`): - // pos: positione of the first character of `key` - // key.length(): self explanatory - // 1: length of character '=' - int offset = pos + key.length() + 1; - // Return a string starting from the offset and with appropriate length - // (difference between the position of the first '&' char after the - // value and `offset`) - return query_.substr(offset, query_.find('&', pos) - offset); -} - -string url::query_value(string key, string fallback) { - string ret = query_value(key); - if (ret.empty()) - return fallback; - return ret; -} - -/* - * Percent-decodes a URL - */ -string percent_decode(const string encoded) { - string ret = ""; - for (auto i = encoded.begin(); i < encoded.end(); i++) { - if (*i == '%') { - std::byte b1 = ::alnum_to_hex(*++i); - std::byte b2 = ::alnum_to_hex(*++i); - - char parsed = static_cast( - (0x10u * std::to_integer(b1)) + - std::to_integer(b2)); - ret += parsed; - } else { - ret += *i; - } - } - - return ret; -} - -} // namespace oim \ No newline at end of file