Skip to content

Commit

Permalink
feat(api): add /api/collections endpoint
Browse files Browse the repository at this point in the history
Closes #313
  • Loading branch information
open-dynaMIX committed Nov 20, 2021
1 parent 313e4d7 commit 450b147
Show file tree
Hide file tree
Showing 8 changed files with 185 additions and 8 deletions.
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ A web based user interface with controls for the [mpv mediaplayer](https://mpv.i
- [audio_devices (string)](#audio-devices--string-)
- [static_dir](#static-dir)
- [htpasswd_path](#htpasswd-path)
- [collections](#collections)
+ [Authentication](#authentication)
* [Dependencies](#dependencies)
+ [Linux](#linux)
Expand Down Expand Up @@ -165,6 +166,18 @@ so stick to absolute paths all the time.

Shortcuts to your homedir like `~/` are not supported.

#### collections

In order to use the basic file-browser API at `/api/collections`, the absolute paths of
to-be exposed directories need to be configured here (semicolon delimited). By default,
responses from `/api/collections` remain empty.

Example:

```
webui-collections="/home/user/Music;/home/user/Videos"
```

### Authentication
There is a very simple implementation of
[Basic Authentication](https://en.wikipedia.org/wiki/Basic_access_authentication).
Expand Down Expand Up @@ -312,7 +325,8 @@ possible in the settings of the webui (client).
| /api/cycle_audio_device | POST | | Cycle trough audio devices. [More information.](#audio-devices-string) |
| /api/speed_set/:speed | POST | `int` or `float` | Set playback speed to `:speed` (defaults to `1` for quick reset) |
| /api/speed_adjust/:amount | POST | `int` or `float` | Multiply playback speed by `:amount` (where `1.0` is no change) |
| /api/loadfile /:url/:mode | POST | :url `string` <br />:mode `string` options: `replace` (default), `append`, `append-play` | Load file to playlist. Together with youtube-dl, this also works for URLs |
| /api/loadfile/:url/:mode | POST | :url `string` <br />:mode `string` options: `replace` (default), `append`, `append-play` | Load file to playlist. Together with youtube-dl, this also works for URLs |
| /api/collections/:path | GET | If no :path is provided, the configured collections are returned. | List directories and files (see [collections](#collections)) |


All POST endpoints return a JSON message. If successful: `{"message": "success"}`, otherwise, the message will contain
Expand Down
106 changes: 99 additions & 7 deletions main.lua
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ local options = {
audio_devices = '',
static_dir = script_path() .. "webui-page",
htpasswd_path = "",
collections = "",
}
read_options(options, "webui")

Expand Down Expand Up @@ -660,13 +661,91 @@ local endpoints = {
local _, success, ret = pcall(mp.commandv, "loadfile", uri, mode)
return handle_post(success, ret)
end
},
["api/collections"] = {
GET = function(request)
local fs_path = request.params[1] or ""

if string.find(fs_path, "%/%.%.") then
return response(404, "plain", "Error: Requested URL /"..request.raw_path.." not found", {})
end

if fs_path == "" then
local json = {}
for _,collection in ipairs(options.collections) do
table.insert(json, {path = collection, ["is-directory"] = true})
end
return response(200, "json", utils.format_json(json), {})
end

if not is_path_in_collection(fs_path) or not is_dir(fs_path) then
return response(404, "plain", "Error: Requested URL /"..request.raw_path.." not found", {})
end

local json = {}

for dir in scandir(fs_path, "d") do
table.insert(json, {path = dir, ["is-directory"] = true})
end

for file in scandir(fs_path, "f") do
table.insert(json, {path = file, ["is-directory"] = false})
end

return response(200, "json", utils.format_json(json), {})
end
}
}

local function file_exists(file)
local f = io.open(file, "rb")
if f then f:close() end
return f ~= nil
function is_path_in_collection(path)
for _,collection in ipairs(options.collections) do
if string.starts(path, collection) then
return true
end
end
return false
end

local function scandir_windows(directory, type)
local w_type = "/a-d"
if type == "d" then
w_type = "/ad"
end

local pfile = assert(io.popen(('chcp 65001 > nul & dir "%s" /s/b %s'):format(directory, w_type), 'r'))
local list = pfile:read('*a')
pfile:close()

return list:gmatch("[^\r\n]+")
end

-- Adapted from https://stackoverflow.com/a/59368633
function scandir(directory, type)
if package.config:sub(1, 1) ~= "/" then
return scandir_windows(directory, type)
end

local pfile = assert(io.popen(("find '%s' -mindepth 1 -maxdepth 1 -type %s -print0"):format(directory, type), 'r'))
local list = pfile:read('*a')
pfile:close()

return string.gmatch(list, '[^%z]+')
end

function _is_file_or_dir(path, property)
local file_info = utils.file_info(path)
if file_info == nil then
return false
end
return file_info[property]
end

local function is_file(file)
return _is_file_or_dir(file, "is_file")
end

function is_dir(path)
return _is_file_or_dir(path, "is_dir")
end

local function lines_from(file)
Expand Down Expand Up @@ -777,9 +856,9 @@ local function handle_request(request, passwd)

if request.method == "GET" then
return handle_static_get(request.raw_path)
elseif file_exists(options.static_dir .. "/" .. request.path) and request.method == "OPTIONS" then
elseif is_file(options.static_dir .. "/" .. request.path) and request.method == "OPTIONS" then
return response(204, "plain", "", {Allow = "GET,OPTIONS"})
elseif file_exists(options.static_dir .. "/" .. request.path) then
elseif is_file(options.static_dir .. "/" .. request.path) then
return response(405, "plain", "Error: Method not allowed", {Allow = "GET,OPTIONS"})
end
return response(404, "plain", "Error: Requested URL /"..request.raw_path.." not found", {})
Expand Down Expand Up @@ -857,7 +936,7 @@ end

local function get_passwd(path)
if path ~= '' then
if file_exists(path) then
if is_file(path) then
return lines_from(path)
end
msg = "Provided htpasswd_path \"" .. path .. "\" could not be found!"
Expand Down Expand Up @@ -917,6 +996,18 @@ local function init_servers()
return servers
end

local function parse_collections()
local collections = {}
for collection in string.gmatch(options.collections, "[^;]+") do
if not is_dir(collection) then
mp.msg.error("No such directory: " .. collection)
else
table.insert(collections, collection)
end
end
options.collections = collections
end

if options.disable then
mp.msg.info("disabled")
message = function() log_osd("disabled") end
Expand All @@ -927,6 +1018,7 @@ end

local passwd = get_passwd(options.htpasswd_path)
local servers = init_servers()
parse_collections()

if passwd ~= 1 then
if next(servers) == nil then
Expand Down
Empty file.
Empty file.
Empty file.
Empty file.
29 changes: 29 additions & 0 deletions tests/snapshots/snap_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,35 @@

snapshots = Snapshot()

snapshots["TestsRequests.test_collections[api/collections-200-mpv_instance0] 1"] = [
{
"is-directory": True,
"path": "/app/scripts/simple-mpv-webui/tests/environment/collection",
}
]

snapshots[
"TestsRequests.test_collections[api/collections/%2Fapp%2Fscripts%2Fsimple-mpv-webui%2Ftests%2Fenvironment%2Fcollection-200-mpv_instance0] 1"
] = [
{
"is-directory": False,
"path": "/app/scripts/simple-mpv-webui/tests/environment/collection/'file b'.log",
},
{
"is-directory": True,
"path": "/app/scripts/simple-mpv-webui/tests/environment/collection/A folder",
},
{
"is-directory": False,
"path": "/app/scripts/simple-mpv-webui/tests/environment/collection/file a.log",
},
{
"is-directory": False,
"path": """/app/scripts/simple-mpv-webui/tests/environment/collection/line
break""",
},
]

snapshots["TestsRequests.test_post_wrong_args[add-&-foo] 1"] = {
"message": "Parameter name contains invalid characters"
}
Expand Down
42 changes: 42 additions & 0 deletions tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,48 @@ def test_not_allowed_methods(mpv_instance, endpoint, method, expected):
assert "Allow" in resp.headers
assert resp.headers["Allow"] == expected

@pytest.mark.parametrize(
"mpv_instance",
[
get_script_opts(
{
"collections": "/app/scripts/simple-mpv-webui/tests/environment/collection"
}
),
],
indirect=["mpv_instance"],
)
@pytest.mark.parametrize(
"endpoint,expected_status",
[
("api/collections", 200),
(
"api/collections/%2Fapp%2Fscripts%2Fsimple-mpv-webui%2Ftests%2Fenvironment%2Fcollection",
200,
),
(
"api/collections/%2Fapp%2Fscripts%2Fsimple-mpv-webui%2Ftests%2Fenvironment%2Fcollectiona%2F..%2F..%2F",
404,
),
(
"api/collections/%2Fapp%2Fscripts%2Fsimple-mpv-webui%2Ftests%2Fenvironment%2Fcollection%2Ffile%20a.log",
404,
),
("api/collections/%2Fsome%2Fother%2Fdir", 404),
(
"api/collections/%2Fapp%2scripts%2Fsimple-mpv-webui%2Ftests%2environment%2Fcollection%2Fnon-existent",
404,
),
],
)
def test_collections(self, endpoint, expected_status, mpv_instance, snapshot):
resp = requests.get(f"{get_uri(endpoint)}")

assert resp.status_code == expected_status

if expected_status == 200:
snapshot.assert_match(sorted(resp.json(), key=lambda k: k["path"]))


@pytest.mark.parametrize(
"endpoint,arg,position",
Expand Down

0 comments on commit 450b147

Please sign in to comment.