From 7a5bddaffce904ab6d877b4e186b475f11ad46f7 Mon Sep 17 00:00:00 2001 From: Reuh Date: Sun, 20 May 2018 17:21:40 +0200 Subject: [PATCH] Initial commit --- .gitignore | 2 + LICENSE | 202 +++++++++++++++++++++ classtoi.lua | 207 +++++++++++++++++++++ daccord.can | 342 +++++++++++++++++++++++++++++++++++ gui.can | 493 +++++++++++++++++++++++++++++++++++++++++++++++++++ mpc.lua | 371 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 1617 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 classtoi.lua create mode 100644 daccord.can create mode 100644 gui.can create mode 100644 mpc.lua diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bbaf4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config.lua +dev diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/classtoi.lua b/classtoi.lua new file mode 100644 index 0000000..8f3d679 --- /dev/null +++ b/classtoi.lua @@ -0,0 +1,207 @@ +--- Reuh's class library version 0.1.4. Lua 5.1-5.3 and LuaJit compatible. +-- Objects and classes behavior are identical, so you can consider this to be somewhat prototype-based. +-- Features: +-- * Multiple inheritance with class(parents...) or someclass(newstuff...) +-- * Every metamethods supported +-- * Everything in a class can be redefined (and will be usable in an object) (except __super) +-- * Preserve parents metamethods if already set +-- * Instanciate with class:new(...) +-- * Test inheritance relations with class/object.is(thing, isThis) +-- * Call object:new(...) on instanciation +-- * If object:new(...) returns non-nil values, they will be returned instead of the instance +-- * Call class.__inherit(class, inheritingClass) when creating a class inheriting the previous class. If class.__inherit returns a value, it will +-- be used as the parent table instead of class, allowing some pretty fancy behavior (it's like an inheritance metamethod). +-- * Implements Class Commons +-- * I don't like to do this, but you can redefine every field and metamethod after class creation (except __index and __super). +-- Not features / Things you may want to know: +-- * Will set the metatable of all parent classes/tables if no metatable is set (the table will be its own metatable). +-- * You can't redefine __super (any __super you define will be only avaible by searching in the default __super contents). +-- * Redefining __super or __index after class creation will break everything (though it should be ok with new, is, __call and everything else). +-- * When creating a new class, the methods new, is, __call, __index and __super will always be redefined, so trying to get theses fields +-- will return the default method and not the one you've defined. However, theses defaults will be replaced by yours automatically on instanciation, +-- except __super and __index, but __index should call your __index and act like you expect. __super will however always be the default one +-- and doesn't proxy in any way yours. +-- * __index metamethods will be called with an extra third argument, which is the current class being searched in the inheritance tree. +-- You can safely ignore it. +-- +-- Please also note that the last universal ancestor of the classes (defined here in BaseClass) sets the default __tostring method +-- and __name attribute for nice class-name-printing. Unlike the previoulsy described attributes and methods however, it is done in a normal +-- inheritance-way and can be rewritten without any problem (rewritting __name is especially useful to easily identify your classes). + +-- Copyright (c) 2016-2017 Étienne "Reuh" Fildadut +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy +-- of this software and associated documentation files (the "Software"), to deal +-- in the Software without restriction, including without limitation the rights +-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +-- copies of the Software, and to permit persons to whom the Software is +-- furnished to do so, subject to the following conditions: +-- +-- 1. The above copyright notice and this permission notice shall be included in +-- all copies or substantial portions of the Software; and +-- 2. You must cause any modified source files to carry prominent notices stating +-- that you changed the files. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. + +-- Lua versions compatibility +local unpack = table.unpack or unpack + +--- All Lua 5.3 metamethods. +local metamethods = { + "__add", "__sub", "__mul", "__div", "__mod", "__pow", "__unm", "__idiv", + "__band", "__bor", "__bxor", "__bnot", "__shl", "__shr", "__tostring", + "__concat", "__len", "__eq", "__lt", "__le", "__index", "__newindex", "__call", "__gc" +} + +local different --- When set, every class __index method will only return a value different from this one. +--- When using a proxied method, contains the last indexed class. +-- This is used for class.is(object); lastIndex will contain class so the is method can react accordingly, without having to be +-- re-set for each class (and therefore doesn't break the "different" mecanism). +local lastIndexed +local makeclass, methods, BaseClass + +--- Classes defaults methods: will be re-set on each class creation. +-- If you overwrite them, you will only be able to call them from an object. +-- Methods starting with a "!" are "proxied methods": they're not present in the class table and will only be called through __index, +-- allowing more control over it (for example having access to lastIndexed). +methods = { + --- Create an object from the class. + -- In pratise, this only subclass the class and call the new method on it, so technically an object is a class. + -- Objects are exaclty like classes, but the __call metamethod will be replaced by one found in the parents, + -- or nil if doesn't exist (so an object is not directly subclassable). + -- (If no __call method is defined in a parent, you won't be able to call the object, but obj.__call will still + -- returns the default (subclassing) method, from one of the parents classes.) + -- The same happens with :new and :is, but since they're not metamethods, if not defined in a parent you won't + -- notice any difference. + -- TL;DR (since I think I'm not really clear): you can redefine __call, :new and :is in parents and use them in objects only. + -- A new object will only be created if calling the method "class:new(...)", if you call for example "class.new(someTable, ...)", it + -- will only execute the constructor defined in the class on someTable. This can be used to execute the parent constructor in a child + -- object, for example. + -- It should also be noted that if the new method returns non-nil value(s), they will be returned instead of the object. + ["!new"] = function(self, ...) + if lastIndexed == self then + local obj, ret = self(), nil + -- Setting class methods to the ones found in parents (we use rawset in order to avoid calling the __newindex metamethod) + different = methods["!new"] rawset(obj, "new", obj:__index("new") or nil) + different = methods["!is"] rawset(obj, "is", obj:__index("is") or nil) + different = methods.__call rawset(obj, "__call", obj:__index("__call") or nil) + different = nil + -- Call constructor + if obj.new ~= methods["!new"] and type(obj.new) == "function" then ret = { obj:new(...) } end + if not ret or #ret == 0 then + return obj + else + return unpack(ret) + end + else + different = methods["!new"] + local new = lastIndexed:__index("new") or nil + different = nil + return new(self, ...) + end + end, + --- Returns true if self is other or a subclass of other. + -- If other is nil, will return true if self is a subclass of the class who called this method. + -- Examples: + -- class.is(a) will return true if a is any class or object + -- (class()):is(class) will return true ((class()) is a subclass of class) + -- (class()).is(class) will return false (class isn't a subclass of (class())) + ["!is"] = function(self, other) + if type(self) ~= "table" then return false end + if other == nil then other = lastIndexed end + if self == other then return true end + for _, t in ipairs(self.__super) do + if t == other then return true end + if t.is == methods["!is"] and t:is(other) then return true end + end + return false + end, + --- Subclass the class: will create a class inheriting self and ... (... will have priority over self). + __call = function(self, ...) + local t = {...} + table.insert(t, self) + return makeclass(unpack(t)) + end, + --- Internal value getting; this follows a precise search order. + -- For example: class(Base1, Base2){stuff} + -- When getting a value from the class, it will be first searched in stuff, then in Base1, then in all Base1 parents, + -- then in Base2, then in Base2 parents. + -- A way to describe this will be search in the latest added tables (from the farthest child to the first parents), from left-to-right. + -- self always refer to the initial table the metamethod was called on, super refers to the class currently being searched for a value. + __index = function(self, k, super) + local proxied = methods["!"..tostring(k)] + if proxied ~= nil and proxied ~= different then -- proxied methods + lastIndexed = self + return proxied + end + for _, t in ipairs((super or self).__super) do -- search in super (will follow __index metamethods) + local val = rawget(t, k) + if val ~= nil and val ~= different then return val end + -- Also covers the case when different search is enabled and the raw t[k] returns an identical value, so the __index metamethod search will be tried for another value. + if getmetatable(t) and getmetatable(t).__index then + val = getmetatable(t).__index(self, k, t) + if val ~= nil and val ~= different then return val end + end + end + end +} + +--- Create a new class width parents ... (left-to-right priority). +function makeclass(...) + local class = { + __super = {} -- parent classes/tables list + } + for k, v in pairs(methods) do -- copy class methods + if k:sub(1, 1) ~= "!" then class[k] = v end -- except proxied methods + end + setmetatable(class, class) + for _, t in ipairs({...}) do -- fill super + if getmetatable(t) == nil then setmetatable(t, t) end -- auto-metatable the table + if type(t.__inherit) == "function" then t = t:__inherit(class) or t end -- call __inherit callback + table.insert(class.__super, t) + end + -- Metamethods query are always raw and thefore don't follow our __index, so we need to manually define thoses. + for _, metamethod in ipairs(metamethods) do + local inSuper = class:__index(metamethod) + if inSuper and rawget(class, metamethod) == nil then + rawset(class, metamethod, inSuper) + end + end + return class +end + +--- The class which will be a parents for all the other classes. +-- We add some pretty-printing default in here. We temporarly remove the metatable in order to avoid a stack overflow. +BaseClass = makeclass { + __name = "class", + __tostring = function(self) + local mt, name = getmetatable(self), self.__name + setmetatable(self, nil) + local str = ("%s (%s)"):format(tostring(name), tostring(self)) + setmetatable(self, mt) + return str + end +} + +--- Class Commons implementation. +-- https://github.com/bartbes/Class-Commons +if common_class and not common then + common = {} + -- class = common.class(name, table, parents...) + function common.class(name, table, ...) + return BaseClass(table, ...){ __name = name, new = table.init } + end + -- instance = common.instance(class, ...) + function common.instance(class, ...) + return class:new(...) + end +end + +return BaseClass diff --git a/daccord.can b/daccord.can new file mode 100644 index 0000000..7f4f541 --- /dev/null +++ b/daccord.can @@ -0,0 +1,342 @@ +--- Yet another MPD client, but this time I wrote it so it's the best. +-- Focused on an instant and hopefully powerful search. +-- Currently only a console interface is available. +-- +-- Dependencies: candran, luasocket, lcurses +-- +-- Highly unintuitive keybinding: +-- * Up goes up, down goes down. +-- * Tab switch between search and current playlist. +-- * Control+W quits. +-- * Control+Space toggle play/pause. +-- * In search: +-- * Control+A add all the results to the playlist. +-- * Enter add currently selected song to the playlist. +-- * Type "TagName=" to trigger tag completion: select a tag and press Enter to select it. Or type it manually and exit tag completion by typing a space. +-- * In current playlist: +-- * Enter play currently selected song. +-- * Delete remove selected song from playlist. +-- +-- Most of what was initialy planned isn't implemented yet. Hopefully all will be finished before you're six feet under. +-- +-- Curently implemented: +-- * Play/pause, current song status +-- * Current playlist, jump to song, remove song +-- * Instant search among all tags or specific tags +-- * Instant tag value completion +-- * Overly optimistic planned features list in a comment in the main file +-- +-- Stuff which is planned: +-- * Sticker search +-- * Search requests where keyword and tag selectors can be assigned with a $probabilty, generating infinite playlists (composer=chopin$.5) +-- * Relational operators for tags and stickers (song ratings) (rating>.5) +-- * Random, single, consume and other MPD play mode are determined by :keywords in the search query (:rand, :limit=5, :asc=Track, etc.) +-- * The endgoal would be that playlists would be entirely determined and contained in a single search query (add some saved storage interface). +-- Which we would therfore mean we can regenerate them on the fly when the MPD database is updated, or probabilities where used in the query. +-- * A non-console GUI. Should be doable considering everything is neatly contained in gui.can, but damn are thoses text widget I made weird. +-- +-- This version of the software is licensed under the terms of the Apache License, version 2 (https://www.apache.org/licenses/LICENSE-2.0.txt). + +-- Copyright 2017-2018 Étienne "Reuh" Fildadut +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +require("candran").setup() +let gui = require("gui") +let mpc = require("mpc") + +-- Constants +let VERSION = "0.0.3" + +-- Configuration +let config = { + -- MPD server + host = "localhost", + port = 6600, + password = "", + -- Interface + songDisplay = { "Track", { "Title", "file" }, "Artist", "Album" } -- list of tags or list of alternative tags (first one to exist will be used) to display for each song +} +(loadfile("config.lua", "t", config) or () end)() + +-- Returns list of fields to display for the song s +let songTable = (s) + let t = {} + for i, field in ipairs(config.songDisplay) do + if type(field) ~= "table" then field = { field } end + t[i] = "∅" + for _, option in ipairs(field) do + if s[option] then + t[i] = tostring(s[option]) + break + end + end + end + return t +end + +-- Connect +mpc.log = () end +mpc = mpc("localhost", 6600) +if config.password ~= "" then + mpc:password(config.password) +end + +-- Valid tags list +let tags = [ + let r, l = mpc:tagtypes() + for _, t in ipairs(l) do + push t.tagtype + end +] +table.insert(tags, "file") + +-- State +let tagCompleting = { + tag = nil, + start = nil, + stop = nil +} + +let results, playlist = {}, {} +let state = "stop" + +gui { + { + type = "tabs", + focused = true, + + width = "extend", + height = "extend", + + -- Search + { + { + id = "prompt", + + type = "input", + focused = true, + + width = "extend", + height = "1em", + + onTextInput = :() + let list = @byId("list") + list:clear() + + -- Tag complete + tagCompleting.tag = nil + if @sub(1, @cursorPosition):match("[A-Za-z_]+=[^\" ]*$") or @sub(1, @cursorPosition):match("[A-Za-z_]+=\"[^\"]*$") then + let start, sel, val, stop + if @sub(1, @cursorPosition):match("[A-Za-z_]+=[^\" ]*$") then + start, sel, val, stop = @sub(1, @cursorPosition):match("()([A-Za-z_]+)=([^\" ]*)()$") + else + start, sel, val, stop = @sub(1, @cursorPosition):match("()([A-Za-z_]+)=\"([^\"]*)()$") + end + + for _, tag in ipairs(tags) do + if tag:lower() == sel:lower() then + let r, songs = mpc:list(tag) + if r then results = songs end + + for _, s in ipairs(results) do + if s[tag]:lower():match(val:lower()) then -- filter val + list:insert{tostring(s[tag])} + end + end + + tagCompleting.tag = tag + tagCompleting.start = start + tagCompleting.stop = stop + + break + end + end + -- Song search + else + -- Build query + let query = {} + + -- Any selectors + let withoutSel = @content:gsub("[A-Za-z_]+=[^\" ]+", ""):gsub("[A-Za-z_]+=\"[^\"]*\"", "") + for word in withoutSel:gmatch("[^%s]+") do + table.insert(query, "any") + table.insert(query, word) + end + + -- Tag selectors + for tag, val in @content:gmatch("([A-Za-z_]+)=([^\" ]+)") do + table.insert(query, tag) + table.insert(query, val) + end + for tag, val in @content:gmatch("([A-Za-z_]+)=\"([^\"]*)\"") do + table.insert(query, tag) + table.insert(query, val) + end + + -- Search + let r, songs = mpc:search(unpack(query)) + if r then results = songs end + + -- Update widget + for _, s in ipairs(results) do + list:insert(songTable(s)) + end + end + end + }, + { + type = "fill", + + width = "extend", + height = "1em", + + fill = "ACS_HLINE" + }, + { + id = "list", + + type = "list", + focused = true, + + width = "extend", + height = "extend", + + onSelect = :(l) + -- Tag complete + if tagCompleting.tag then + let prompt = @byId("prompt") + if results[l[1]][tagCompleting.tag]:match(" ") then + prompt:replace(tagCompleting.start, tagCompleting.stop, tagCompleting.tag.."=\""..results[l[1]][tagCompleting.tag].."\"") + else + prompt:replace(tagCompleting.start, tagCompleting.stop, tagCompleting.tag.."="..results[l[1]][tagCompleting.tag]) + end + -- Song search + else + for _, i in ipairs(l) do + if results[i] then + mpc:add(results[i].file) + end + end + @byId("playlist"):updateAfter(1) + + let status = @byId("status") + status:set("Added "..#l.." songs to the current playlist") -- FIXME + end + end + } + }, + + -- Playlist + { + { + id = "playlist", + + type = "list", + focused = true, + + width = "extend", + height = "extend", + + updateInterval = 5, + + onUpdate = :() + let r, songs = mpc:playlistinfo() + if r then playlist = songs end + + for i, s in ipairs(playlist) do + let item = songTable(s) + if @content[i] then + if @content[i] ~= item then + @remove(i) + @insert(i, item) + end + else + @insert(i, item) + end + end + + while #@content > #playlist do + @remove() + end + end, + + onSelect = :(l) + if #playlist > 0 then + mpc:playid(playlist[l[1]].Id) + end + end, + + onControl = :(control) + if control == "delete" then + mpc:deleteid(playlist[@selected].Id) + @remove(@selected) + end + end + } + } + }, + -- Status bar + { + id = "play-position", + type = "slider", + + width = "extend", + height = "1em" + }, + { + id = "status", + type = "label", + focused = true, + + width = "extend", + height = "1em", + + content = "No current song", + + updateInterval = 1, + + onUpdate = :() + let r, s = mpc:currentsong() + if r then + if s.file then + @set(table.concat(songTable(s), " - ")) + else + @set("daccord v"..VERSION.." - nothing playing") + end + end + + let r, s = mpc:status() + if r then + state = s.state + pos = @byId("play-position") + pos:setHead(state == "play" and "▶️" or "⏸") + pos:setMax(s.duration) + pos:set(s.elapsed) + end + end, + + onControl = :(control) + if control == "space" then + if state == "play" then + mpc:pause() + else + mpc:play() + end + end + @onUpdate() + end + }, + + onClose = :() @exit() end +} diff --git a/gui.can b/gui.can new file mode 100644 index 0000000..7ea324c --- /dev/null +++ b/gui.can @@ -0,0 +1,493 @@ +--- Yet another badly designed and implemented GUI library, but this time I wrote it so it's the best. +-- Part of daccord. See daccord.can for more information. + +-- Copyright 2017-2018 Étienne "Reuh" Fildadut +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +let curses = require("curses") +let class = require("classtoi") +let sleep = require("socket").sleep + +let screen + +os.setlocale("") + +let everyWidget = {} + +let widget = class { + _exit = false, + + _redraw = true, + x = 0, + y = 0, + w = 0, + h = 0, + + parent = {}, + focused = false, + + updateInterval = -1, + _nextUpdate = os.time(), -- -1 = no update + + new = :(data) + table.insert(everyWidget, self) + + -- Copy properties + for k, v in pairs(data) do + @[k] = v + end + + -- Dimensions + if not @parent._firstX then @parent._firstX, @parent._firstY = 0, 0 end + @x, @y = @parent._firstX, @parent._firstY + + if @width == "extend" @w = @parent.w - @x + elseif @width:match("%d+em$") @w = tonumber(@width:match("%d+")) + if @height == "extend" then + @h = @parent.h - @y + @parent._lastVerticalExtend = self -- will be resized if vertical space is needed + @parent._afterLastVerticalExtend = {} + elseif @height:match("%d+em$") @h = tonumber(@height:match("%d+")) + + if @y >= @parent.h then -- resize + @parent._lastVerticalExtend:_resize(@parent._lastVerticalExtend.w, @parent._lastVerticalExtend.h - @h) + @parent._firstY -= @h + @y -= @h + for _, el in ipairs(@parent._afterLastVerticalExtend) do -- move widgets + el.y -= @h + end + end + + @parent._firstX += @w + if @parent._firstX >= @parent.w then @parent._firstX = 0 end -- newline + @parent._firstY += @h + if @parent._lastVerticalExtend ~= self and @parent._afterLastVerticalExtend then table.insert(@parent._afterLastVerticalExtend, self) end + + -- Setup + if @_setup then @_setup() end + + screen:move(@parent.y + @y + @h, @parent.x + @x + @w) + end, + + exit = :() + @_exit = true + end, + + byId = :(id) + for _, el in ipairs(everyWidget) do + if el.id == id return el + end + error("no element with id "..tostring(id)) + end, + + updateAfter = :(time) + @_nextUpdate = os.time() + time + end +} + +let widgets = setmetatable({ + fill = widget { + _draw = :() + if type(@fill) == "string" then @fill = curses[@fill] end + for y = @parent.y + @y, @parent.y + @y + @h - 1 do + screen:move(y, @parent.x + @x) + for x = @parent.x + @x, @parent.x + @x + @w - 1 do + screen:addch(@fill or 32) + end + end + end + }, + + input = widget { + content = "", + cursorPosition = 1, + _input = :(charbuffer, control) + let y, x = @parent.y + @y, @parent.x + @x + @cursorPosition-1 + if control == "backspace" then + screen:mvdelch(y, x-1) + if @cursorPosition > 1 then + if @cursorPosition == 2 then -- utf8.offset(s, 0) returns the start of the last character, ie something we don't want + @content = @content:sub(utf8.offset(@content, @cursorPosition)) + else + @content = @content:sub(1, utf8.offset(@content, @cursorPosition-1)-1) + .. @content:sub(utf8.offset(@content, @cursorPosition)) + end + @cursorPosition -= 1 + @onTextInput() + end + elseif control == "right" then + if @cursorPosition <= utf8.len(@content) then + screen:addstr(@content:sub(utf8.offset(@content, @cursorPosition), utf8.offset(@content, @cursorPosition+1)-1)) + @cursorPosition += 1 + end + elseif control == "left" then + if @cursorPosition > 1 then + screen:move(y, x-1) + @cursorPosition -= 1 + end + elseif charbuffer then + screen:move(y, x) + screen:winsstr(charbuffer) + screen:move(y, x+1) + @content = @content:sub(1, utf8.offset(@content, @cursorPosition)-1) + .. charbuffer + .. @content:sub(utf8.offset(@content, @cursorPosition)) + @cursorPosition += 1 + @onTextInput() + end + end, + _draw = :() + for y = @parent.y + @y, @parent.y + @y + @h - 1 do + screen:move(y, @parent.x + @x) + for x = @parent.x + @x, @parent.x + @x + @w - 1 do + screen:addch(32) + end + end + screen:mvaddstr(@parent.y + @y, @parent.x + @x, @content) + end, + _placeCursor = :() + screen:move(@parent.y + @y, @parent.x + @x + @cursorPosition-1) + return true + end, + sub = :(start, stop=utf8.len(@content)) + if stop < 1 or start >= utf8.len(@content) then + return "" + else + return @content:sub(utf8.offset(@content, start), (utf8.offset(@content, stop+1) or (#@content+1))-1) + end + end, + replace = :(start, stop, newText) + if @cursorPosition >= stop then + @cursorPosition += utf8.len(newText) - (stop - start) + end + @content = @sub(1, start-1) .. newText .. @sub(stop+1) + @onTextInput() + @_redraw = true + end, + onTextInput = :() end + }, + + list = widget { + content = {}, + columnWidth = {}, + selected = 1, + scroll = 0, + _redraw = true, + _input = :(charbuffer, control) + if control == "up" and @selected > 1 then + @selected -= 1 + if @selected == @scroll then + @scroll -= 1 + end + @_redraw = true + elseif control == "down" and @selected < #@content then + @selected += 1 + if @selected == @scroll + @h + 1 then + @scroll += 1 + end + @_redraw = true + end + + if control == "enter" then + @onSelect({@selected}) + elseif control == "A" then + let len = #@content + @onSelect([for i=1, len do i end]) + end + end, + _draw = :() + let oY, oX = screen:getyx() + for y = @parent.y + @y, @parent.y + @y + @h -1 do -- clear + screen:mvaddstr(y, @parent.x + @x, (" "):rep(@w)) + end + screen:move(@parent.y + @y, @parent.x + @x) + for i=@scroll+1, @scroll + @h do -- draw + if i == @selected then + screen:standout() + end + let colx = @parent.x+@x + for c=1, #@columnWidth do + screen:mvaddstr(@parent.y+@y+i-1-@scroll, colx, (""):rep(@columnWidth[c])) -- FIXME: too lazy to do this the right way and extract utf8 substrings (also should probably check if the thing doesn't go too right) + screen:mvaddstr(@parent.y+@y+i-1-@scroll, colx, @content[i] and @content[i][c] or "") + colx += @columnWidth[c] + end + if i == @selected then + screen:standend() + end + end + screen:move(oY, oX) + end, + insert = :(pos, item) + if item then + table.insert(@content, pos, item) + if @selected >= pos and #@content > 1 then + @selected += 1 + end + else + item = pos + table.insert(@content, item) + end + if #@content == 1 then -- column count is determined by 1st item (other items can do what they want, this isn't a dictatorship) + @columnWidth = [for _=1,#item do 0 end] + end + if #item >= #@columnWidth then -- if the column fits into our dictatorship, update column width + for c=1, #@columnWidth do + if utf8.len(item[c]) > @columnWidth[c] then + @columnWidth[c] = utf8.len(item[c]) + 1 + end + end + end + @_redraw = true + end, + remove = :(pos=#@content) + table.remove(@content, pos) + if @selected >= pos and @selected > 1 then + @selected -= 1 + end + @_redraw = true + end, + clear = :() + @.content = {} + --@.columnWidth = {} + @_redraw = true + @selected = 1 + @scroll = 0 + end, + onSelect = :() end + }, + + tabs = widget { + selected = 1, + children = {}, + _children = {}, + _setup = :() + for i, tab in ipairs(@) do + @children[i] = { x = @x, y = @y, w = @w, h = @h } + for _, el in ipairs(tab) do + el.parent = @children[i] + table.insert(@children[i], widgets[el.type]:new(el)) + end + end + @_children = @children[@selected] + end, + _input = :(charbuffer, control) + if control == "tab" then + @selected += 1 + if @selected > #@ then @selected = 1 end + @_children = @children[@selected] + for _, el in ipairs(@_children) do -- Force redraw + el._redraw = true + end + end + end, + _resize = :(w, h) + @w, @h = w, h + for i, tab in ipairs(@) do + for _, el in ipairs(@children[i]) do + if el.x + el.w > w then el.w = w - el.x end + if el.y + el.h > h then el.h = h - el.y end + end + end + end + }, + + label = widget { + content = "Label", + _draw = :() + screen:mvaddstr(@parent.y + @y, @parent.x + @x, @content .. (" "):rep(@w - #@content)) + end, + set = :(str) + @content = str + @_redraw = true + end + }, + + slider = widget { + min = 0, + max = 1, + current = 0, + head = "⏸", + _draw = :() + let len = math.ceil((@current - @min) / (@max - @min) * @w) + screen:mvaddstr(@parent.y + @y, @parent.x + @x, ("="):rep(len-1)) + screen:addstr(@head .. (" "):rep(@w - len)) + end, + set = :(current) + @current = current + @_redraw = true + end, + setMax = :(max) + @max = max + @_redraw = true + end, + setMin = :(min) + @min = min + @_redraw = true + end, + setHead = :(head) + @head = head + @_redraw = true + end + } +}, { + __index = (t, k) + error("unknown widget "..tostring(k)) + end +}) + +let recursiveApply = (list, fn) + for _, el in ipairs(list) do + if el._widget then + el = el._widget + end + fn(el) + if el._children then + recursiveApply(el._children, fn) + end + end +end + +return (ui) + xpcall(() + -- Init + if not screen then + screen = curses.initscr() + curses.cbreak() + curses.echo(false) + screen:nodelay(true) + end + + -- Create widgets + screen:clear() + let h, w = screen:getmaxyx() + let parent = { + x = 0, y = 0, + w = w, h = h + } + for _, el in ipairs(ui) do + el.parent = parent + el._widget = widgets[el.type]:new(el) + end + + -- Update loop + while not widget._exit do + -- Input + local c = screen:getch() + if c and c < 256 then + let charbuffer = string.char(c) + let control + if c > 127 then -- multibyte char + charbuffer ..= string.char(screen:getch()) + if c > 223 then + charbuffer ..= string.char(screen:getch()) + if c > 239 then + charbuffer ..= string.char(screen:getch()) + end + end + end + if curses.unctrl(c):match("^%^") then -- control char + charbuffer = nil + let k = curses.unctrl(c) + if k == "^?" then + control = "backspace" + elseif k == "^W" then + control = "close" + elseif k == "^J" then + control = "enter" + elseif k == "^I" then + control = "tab" + elseif k == "^@" then + control = "space" + elseif k == "^[" then + let k = string.char(screen:getch()) .. string.char(screen:getch()) + if k == "[C" then + control = "right" + elseif k == "[D" then + control = "left" + elseif k == "[A" then + control = "up" + elseif k == "[B" then + control = "down" + elseif k == "[3" then + k ..= string.char(screen:getch()) + if k == "[3~" then + control = "delete" + else + error("unknown control "..tostring(k)) + end + else + error("unknown control "..tostring(k)) + end + elseif k:match("^%^[A-Z]$") then + control = k:match("^%^([A-Z])$") + else + error("unknown control "..tostring(k)) + end + end + + recursiveApply(ui, (el) + if el.focused and el._input then + el:_input(charbuffer, control) + end + if el.focused and control and el.onControl then + el:onControl(control) + end + end) + + if control == "close" then + if ui.onClose then ui.onClose(widget) end + end + end + + -- Update + recursiveApply(ui, (el) + if el._nextUpdate ~= -1 and os.difftime(el._nextUpdate, os.time()) <= 0 then + if el.onUpdate then el:onUpdate() end + if el.updateInterval ~= -1 then + el._nextUpdate += el.updateInterval + else + el._nextUpdate = -1 + end + end + end) + + -- Redraw + recursiveApply(ui, (el) + if el._redraw and el._draw then + el._redraw = false + el:_draw() + end + end) + + -- Place cursor + let cursorVis = 0 + recursiveApply(ui, (el) + if el._placeCursor and el:_placeCursor() then + cursorVis = 2 + end + end) + curses.curs_set(cursorVis) + + -- Done + screen:refresh() + sleep(0.03) + end + + curses.endwin() + end, (err) + curses.endwin() + print(require("candran").messageHandler(err)) + os.exit(2) + end) +end diff --git a/mpc.lua b/mpc.lua new file mode 100644 index 0000000..f9dedc9 --- /dev/null +++ b/mpc.lua @@ -0,0 +1,371 @@ +--- Music Player Daemon (MPD) client library. +-- +-- Alows basic manipulation of the MPD protocol. This provides the client part, you will need a MPD-compatible server running on +-- another machine. +-- +-- Please see the MPD command reference on the [official documentation](http://www.musicpd.org/doc/protocol/command_reference.html). +-- +-- You may want to generate a documentation for this file using LDoc: `ldoc .`. However, LDoc doesn't seem to +-- appreciate my coding style so it doesn't display everything, but the rendered documentation should be usable enough. +-- If you didn't understand something or think you missed something, please read the [source file](source/mpc.lua.html), which is +-- largely commented. +-- +-- Variables prefixed with a `_` are private. Don't use them if you don't know what you're doing. +-- +-- *Requires* `luasocket` or `ctr.socket` (ctrµLua). +-- +-- When I started this project in october 2015, there weren't really any other decent MPD client library for Lua. +-- The ones available were either uncomplete or outdated, regarding either MPD or Lua. So I made this. +-- I didn't want to publish it until I used it in something, since it's a pretty small library (I didn't know things like left-pad existed and people were ok with it). +-- Well, too bad for me. As it turns out, today several new libraries have poped up and they look quite usable. +-- Well, it's too late. Now this is yet another MPD library, but this time I wrote it so it's the best. +-- +-- @author Reuh +-- @release 0.2.0 + +-- Copyright (c) 2015-2018 Étienne "Reuh" Fildadut +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy +-- of this software and associated documentation files (the "Software"), to deal +-- in the Software without restriction, including without limitation the rights +-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +-- copies of the Software, and to permit persons to whom the Software is +-- furnished to do so, subject to the following conditions: +-- +-- 1. The above copyright notice and this permission notice shall be included in +-- all copies or substantial portions of the Software; and +-- 2. You must cause any modified source files to carry prominent notices stating +-- that you changed the files. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. + +local socket +if package.loaded["ctr.socket"] then + socket = require("ctr.socket") +else + socket = require("socket") +end + +-- Parsing commands response helpers. +local function cast(str) + return tonumber(str, 10) or str +end +local function parseList(lines, starters) + local list = {} + if lines[1] ~= "OK" then + if not starters then + starters = { lines[1]:match("^[^:]+") } -- first tag for each song + end + local cursong -- currently parsing song + for _, l in ipairs(lines) do + if l == "OK" then + break + else + for _, startType in ipairs(starters) do + if l:match(startType..":") then -- next song + cursong = {} + table.insert(list, cursong) + break + end + end + cursong[l:match("^[^:]+")] = cast(l:match("^[^:]+%: (.*)$")) -- add tag + end + end + end + return list +end +local function parseSticker(dict) + for k, v in pairs(dict) do + if k == "sticker" and v:match(".+=.+") then + dict[k] = { [v:match("(.+)=.+")] = cast(v:match(".+=(.+)")) } + end + if type(v) == "table" then + parseSticker(v) + end + end + return dict +end + +--- Music Player Client object. +-- Used to manipulate a single MPD server. +-- @type mpc +local mpc = { + ---## PUBLIC METHODS ##--- + -- The functions you should use. + + --- Connect to a MPD server. + -- @tparam string address server address + -- @tparam number port server port + -- @see mpcmodule + init = function(self, address, port) + self._address = address + self._port = port + + self:_connect() + end, + + --- Execute a command. + -- A shortcut (and generally more useful version) is `mpc:commandName([arguments, ...])`, which will also try return a parsed version of the result + -- in a nicer Lua representation as a second return value. See COMMANDS OVERWRITE. + -- @tparam string command command name + -- @tparam[opt] any ... command arguments + -- @treturn[1] boolean true if it was a success, false otherwise + -- @treturn[1] table List of the lines of the response (strings) + -- @treturn[2] nil nil if nothing was received + command = function(self, command, ...) + self:_send({ command, ... }) + return self:_receive(true) + end, + + --- Called each time something can be logged. + -- You will want to and can overwrite this function. + -- @tparam string message message string + -- @tparam[opt] any ... arguments to pass to message:format(...) + log = function(self, message, ...) + print(("[mpc.lua@%s:%s] %s"):format(self._address, self._port, message:format(...))) + end, + + ---## COMMANDS OVERWRITE ##--- + -- Theses functions overwrite some MPD's command, to add somme pratical features. + -- In particular, every MPD command which return something will be wrapped here so the second return value is either (the list of lines is still available as a third return value): + -- * A list of dictionnaries, if the command can return a list of similar elements (playlistinfo, search, ...) + -- * A dictionnary if the command return something which isn't a list (status, currentsong, ...) + -- The key names are the same used by MPD in the responses. Numbers will be automatically casted to a Lua number, every other value will be a string. + -- Commands which return nothing will return nil as a second return value. + -- Most overwrites are generated at the end of the file using the overwrites lists defined below. + -- Signature for every command called through `mpc:commandName`: + -- @tparam[opt] any ... command arguments + -- @treturn[1] boolean true if it was a success + -- @treturn[1] table dictionnary or list of dictionnary containing the response data, or nil if there is no response data to be expected + -- @treturn[1] table List of the lines of the response (strings) + -- @treturn[2] boolean false if there was an error + -- @treturn[2] string error message + -- @treturn[3] nil nil if nothing was received + + --- Sends the close command and close the socket. + -- This will log any uncomplete message received, if any. Returns nothing. + -- Call this when you are done with the mpc object. + -- Note however, that the client will automatically reconnect if you reuse it later. + close = function(self) + self:_send("close") + self._socket:close() + + if #self._buffer > 0 then + self:log("UNCOMPLETLY RECEIVED MESSAGE:\n\t%s", table.concat(self._buffer, "\n\t")) + end + self:log("CLOSED") + end, + + --- Sends the password command. + -- This will store the password in a variable so it can be resent in case of disconnection. + -- @tparam string password the password + password = function(self, password) + self._password = password + local success, lines = self:command("password", password) + return success, not success and lines[1] or nil, lines + end, + + --- Returns a chunk of albumart. + -- See the MPD documentation. The raw bytes will be stored in the `chunk` field of the response dictionnary. + albumart = function(self, ...) + local success, lines = self:command("albumart", ...) + return success, success and { + size = lines[1]:match("size: (%d+)"), + binary = lines[2]:match("binary: (%d+)"), + chunk = table.concat(lines, "", 3, #lines-1) + } or lines[1], lines + end, + + --- Sticker commands. + -- Will parse stickers values in dictionnary.sticker.name = value. + sticker = function(self, action, ...) + if action == "list" or action == "find" then + local success, lines = self:command("sticker", action, ...) + return success, success and parseSticker(parseList(lines)) or lines[1], lines + elseif action == "get" then + local success, lines = self:command("sticker", action, ...) + return success, success and parseSticker(parseList(lines)[1]) or lines[1], lines + else + local success, lines = self:command("sticker", action, ...) + return success, not success and lines[1] or nil, lines + end + end, + + --- Commands which will return a list of dictionnaries. + _overwriteDictList = { + "idle", -- Querying MPD's status + "playlistfind", "playlistid", "playlistinfo", "playlistsearch", "plchanges", "plchangesposid", -- The current playlist + "listplaylist", "listplaylistinfo", "listplaylists", -- The current playlist + "count", "find", "list", "listall", "listallinfo", "search", listfiles = { "file", "directory" }, lsinfo = { "file", "directory" }, -- The music database + "listmounts", "listneighbors", -- Mounts and neighbors + "tagtypes", -- Connection settings + "listpartitions", -- Partition commands + "outputs", -- Audio output devices + "commands", "notcommands", "urlhandlers", "decoders", -- Reflection + "channels", "readmessages" -- Client to client + }, + + --- Commands which will return a dictionnary. + _overwriteDict = { + "currentsong", "status", "stats", -- Querying MPD's status + "readcomments", "update", "rescan", -- The music database + "config" -- Reflection + }, + + ---## PRIVATE FUNCTIONS ##--- + -- Theses functions are intended to be used internally by mpc.lua. + -- You can use them but they weren't meant to be used from the outside. + + _socket = nil, -- socket object + + _address = "", -- server address string + _port = 0, -- server port integer + + _password = "", -- server password string + + _buffer = {}, -- received message buffer table + + --- Connects to the server. + -- The fuction will auto-login if a password was previously set. + -- If the client was already connected, it will disconnect and then reconnect. + _connect = function(self) + if self._socket then self._socket:close() end + + self._socket = assert(socket.tcp()) + assert(self._socket:connect(self._address, self._port)) + if self._socket.settimeout then self._socket:settimeout(0.1) end + + assert(self:_receive(), "something went terribly wrong") + self:log("CONNECTED") + + if self._password ~= "" then self:password(self._password) end + end, + + --- Send a list of commands to the server. + -- @tparam table commands List of commands (strings or tables). If table, the table represent the arguments list. + -- ie, this `:_send({"play", 18})` is equivalent to `:_send("play 18")`. + _send = function(self, ...) + local commands = {...} + for i, v in ipairs(commands) do + if type(v) == "table" then + local cmd = v[1] + for j, k in ipairs(v) do + if j > 1 then -- bweh + if type(k) == "string" then + cmd = cmd..(" %q"):format(k) + elseif type(k) == "table" then + cmd = cmd..(k[1] or "")..":"..(k[2] or "") + else + cmd = cmd.." "..tostring(k) + end + end + end + commands[i] = cmd + end + end + + local success, err = self._socket:send(table.concat(commands, "\n").."\n") + if not success then + if err == "closed" then + self:log("CONNECTION CLOSED, RECONNECTING") + self:_connect() + self:_send(...) + else + error("error while sending data to MPD server: "..err) + end + end + + self:log("SENT:\n\t%s", table.concat(commands, "\n\t")) + end, + + --- Receive a single server response. + -- @tparam boolean block true to block until a message is received + -- @treturn[1] boolean true if was a success, false otherwise + -- @treturn[1] table List of the lines of the response (strings) + -- @treturn[2] nil nil if nothing was received + _receive = function(self, block) + local success + local received + + repeat + local response, err = self._socket:receive() + + if response and response ~= "" then + table.insert(self._buffer, response) + + if response:sub(1, 2) == "OK" or response:sub(1, 3) == "ACK" then + success = response:sub(1, 2) == "OK" + + received = self._buffer + self._buffer = {} + + break + end + + elseif err == "closed" then + self:log("CONNECTION CLOSED, RECONNECTING") + self:_connect() + return self:_receive() + elseif err ~= "timeout" then + error("error while receiving data from MPD server: "..err) + end + until not block and (not response or response == "") + + if not received then return nil end + + self:log("RECEIVED:\n\t%s", table.concat(received, "\n\t")) + return success, received + end +} + +-- Overwrites for commands which return lists of dictionnaries +for k, v in pairs(mpc._overwriteDictList) do + local command, starters + if type(k) == "number" then command = v + else command, starters = k, v end + mpc[command] = function(self, ...) + local success, lines = self:command(command, ...) + return success, success and parseList(lines, starters) or lines[1], lines + end +end +-- Overwrites for commands which return a single dictionnary +for k, v in pairs(mpc._overwriteDict) do + local command, starters + if type(k) == "number" then command = v + else command, starters = k, v end + mpc[command] = function(self, ...) + local success, lines = self:command(command, ...) + return success, success and parseList(lines, starters)[1] or lines[1], lines + end +end + +--- The module returns a constructor function. +-- Calling this function will create a new MPC object. +-- The arguments will be passed to the object's `:init` method. +-- @within Module +-- @function mpcmodule +-- @usage local mpc = require("mpc")("localhost", 6600) -- where "localhost", 6600 are :init's arguments +return setmetatable(mpc, { + __call = function(t, ...) + local object = setmetatable({}, { + __index = function(t, k) + if mpc[k] then + return mpc[k] + elseif k:sub(1, 1) ~= "_" then + return function(self, ...) + local success, lines = mpc.command(self, k, ...) + return success, not success and lines[1] or nil, lines + end + end + end + }) + object:init(...) + return object + end +})