├── lib └── resty │ └── danmaku │ ├── init.lua │ ├── util.lua │ ├── stat.lua │ ├── broadcaster.lua │ └── subscriber.lua └── README.md /lib/resty/danmaku/init.lua: -------------------------------------------------------------------------------- 1 | local subscriber = require "resty.danmaku.subscriber" 2 | local boradcaster = require "resty.danmaku.broadcaster" 3 | local util = require "resty.danmaku.util" 4 | 5 | local _M = util.new_tab(0, 3) 6 | local mt = { __index = _M } 7 | 8 | function _M.run() 9 | local liveid = tonumber(ngx.var.liveid) 10 | if not liveid then 11 | ngx.exit(444) 12 | end 13 | -- check if broadcast is running 14 | local br = util.get_broadcaster(liveid) 15 | if not br then 16 | -- initialize new broadcaster instance 17 | ngx.log(ngx.ERR, "initializing new broadcaster ", liveid) 18 | br = boradcaster:new({liveid = liveid}) 19 | ngx.thread.spawn(br.run, br) 20 | end 21 | 22 | local sb = subscriber:new({liveid = liveid}) 23 | -- run subscriber, this will not exit until connection closed 24 | ngx.thread.spawn(sb.send_loop, sb) 25 | ngx.thread.spawn(sb.recv_loop, sb) 26 | -- connection closed here, remove from subscriber list 27 | 28 | end 29 | 30 | return _M 31 | -------------------------------------------------------------------------------- /lib/resty/danmaku/util.lua: -------------------------------------------------------------------------------- 1 | 2 | local _M = { _VERSION = '0.01' } 3 | 4 | _M.shm_key = "dmk" 5 | _M.shared = {} 6 | 7 | local ok, new_tab = pcall(require, "table.new") 8 | if not ok then 9 | new_tab = function (narr, nrec) return {} end 10 | end 11 | 12 | _M.new_tab = new_tab 13 | 14 | function _M.random_str(l, seed) 15 | local s = 'abcdefghijklmnhopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' 16 | 17 | local ret ='' 18 | -- os.time() is precise to second 19 | math.randomseed(ngx.now() * 1000 + ngx.crc32_short(seed or "")) 20 | for i=1 ,l do 21 | local pos = math.random(1, string.len(s)) 22 | ret = ret .. string.sub(s, pos, pos) 23 | end 24 | 25 | return ret 26 | end 27 | 28 | function _M.get_broadcaster(liveid) 29 | -- ngx.log(ngx.ERR, "get_bro ->", liveid, "<-", tostring(_M.shared['broadcaster_' .. liveid])) 30 | return _M.shared['broadcaster_' .. liveid] 31 | end 32 | 33 | function _M.set_broadcaster(liveid, tb) 34 | _M.shared['broadcaster_' .. liveid] = tb 35 | end 36 | 37 | function _M.get_broadcaster_gid(liveid) 38 | local rds = _M.get_redis() 39 | local rt = rds:get("broadcaster_gid_" .. liveid) 40 | _M.close_redis(rds) 41 | return rt 42 | end 43 | 44 | function _M.set_broadcaster_gid(liveid, v) 45 | local rds = _M.get_redis() 46 | local rt = rds:set("broadcaster_gid_" .. liveid, v) 47 | _M.close_redis(rds) 48 | return rt 49 | 50 | end 51 | 52 | function _M.get_subscriber(uid) 53 | return _M.shared['subscriber_' .. uid] 54 | end 55 | 56 | function _M.set_subscriber(uid, tb) 57 | _M.shared['subscriber_' .. uid] = tb 58 | end 59 | 60 | function _M.get_redis() 61 | local redis = require "resty.redis" 62 | local rds, err = redis:new() 63 | if not rds then 64 | ngx.log(ngx.ERR, "[LI] failed to instantiate redis: ", err) 65 | return nil 66 | end 67 | rds:set_timeout(1000) -- 1 sec 68 | local ok, err = rds:connect("127.0.0.1", 6379) 69 | if not ok then 70 | ngx.log(ngx.ERR, "[LI] failed to connect: ", err) 71 | return nil 72 | end 73 | return rds 74 | end 75 | 76 | function _M.close_redis(rds) 77 | local ok, err = rds:set_keepalive(10000, 10) 78 | if ok == nil then 79 | rds:close() 80 | end 81 | end 82 | 83 | return _M 84 | -------------------------------------------------------------------------------- /lib/resty/danmaku/stat.lua: -------------------------------------------------------------------------------- 1 | local util = require "resty.danmaku.util" 2 | local _M = {} 3 | 4 | function _M._get_stat() 5 | local rds = util.get_redis() 6 | local ret = '[Total subscriber]\n' .. 7 | 'Create: '.. tostring(rds:get('sub_cnt_create')) .. '\n' .. 8 | 'Destory: '.. tostring(rds:get('sub_cnt_destory')) .. '\n' .. 9 | '[Total broadcaster]\n' .. 10 | 'Create: '.. tostring(rds:get('brd_cnt_create')) .. '\n' .. 11 | 'Destory: '.. tostring(rds:get('brd_cnt_destory')) .. '\n' .. 12 | '[Total danmaku]\n' .. 13 | 'Total: ' .. tostring(rds:get('dm_cnt_all')) .. '\n' .. 14 | '[Live rooms]\n' 15 | util.close_redis(rds) 16 | 17 | if util.shared == nil then 18 | ret = ret .. "-- no rooms" 19 | return ret 20 | end 21 | 22 | for k, _ in pairs(util.shared) do 23 | local m, err = ngx.re.match(k, "broadcaster_(\\d+)") 24 | if m then 25 | local liveid = m[1] 26 | local b = util.get_broadcaster(liveid) 27 | ret = ret .. "Room=" .. tostring(liveid) .. " Subscribers=" .. tostring(b.subs_count) 28 | if b.dying > 0 then 29 | ret = ret .. " Dying=" .. tostring(b.dying) .. 30 | "(" .. tostring(b.dying - os.time()) 31 | end 32 | end 33 | end 34 | return ret 35 | end 36 | 37 | function _M._sub_create() 38 | local rds = util.get_redis() 39 | if rds:get('sub_cnt_create') == nil then 40 | rds:set('sub_cnt_create', 1) 41 | else 42 | rds:incr('sub_cnt_create', 1) 43 | end 44 | util.close_redis(rds) 45 | end 46 | 47 | function _M._sub_destory() 48 | local rds = util.get_redis() 49 | if rds:get('sub_cnt_destory') == nil then 50 | rds:set('sub_cnt_destory', 1 ) 51 | else 52 | rds:incr('sub_cnt_destory', 1) 53 | end 54 | util.close_redis(rds) 55 | end 56 | 57 | 58 | function _M._brd_create() 59 | local rds = util.get_redis() 60 | if rds:get('brd_cnt_create') == nil then 61 | rds:set('brd_cnt_create', 1) 62 | else 63 | rds:incr('brd_cnt_create', 1) 64 | end 65 | util.close_redis(rds) 66 | end 67 | 68 | function _M._brd_destory() 69 | local rds = util.get_redis() 70 | if rds:get('brd_cnt_destory') == nil then 71 | rds:set('brd_cnt_destory', 1) 72 | else 73 | rds:incr('brd_cnt_destory', 1) 74 | end 75 | util.close_redis(rds) 76 | end 77 | 78 | function _M._sent_dm() 79 | local rds = util.get_redis() 80 | if rds:get('dm_cnt_all') == nil then 81 | rds:set('dm_cnt_all', 1) 82 | else 83 | rds:incr('dm_cnt_all', 1) 84 | end 85 | util.close_redis(rds) 86 | end 87 | 88 | return _M 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Name 2 | ==== 3 | 4 | lua-resty-danmaku - Danmaku server based on the ngx_lua cosocket API 5 | 6 | Table of Contents 7 | ================= 8 | 9 | 10 | - Description 11 | - Synopsis 12 | - Sample configuration 13 | - TODO 14 | - Copyright and License 15 | - See Also 16 | 17 | 18 | 19 | 20 | Description 21 | =========== 22 | 23 | This Lua library is a [Danmaku](https://zh.wikipedia.org/zh/%E8%A7%86%E9%A2%91%E5%BC%B9%E5%B9%95) server based on ngx_lua module. Currently this server can only work on Websocket protocol. 24 | 25 | Note that at least [ngx_lua 0.10.0](https://github.com/chaoslawful/lua-nginx-module/tags) or [ngx_openresty 1.9.7.2](http://openresty.org/#Download) is required. Also, [lua-resty-websocket](https://github.com/openresty/lua-resty-websocket) is required to accept Websocket connections. 26 | 27 | Synopsis 28 | ======== 29 | 30 | [Back to TOC](#table-of-contents) 31 | 32 | 33 | Sample configuration 34 | ======== 35 | 36 | ``` 37 | server { 38 | listen 80; 39 | listen 443 ssl http2; 40 | 41 | location ~ /danmaku/(\d+) { 42 | set $liveid $1; 43 | set $keep_alive_timeout 30000; 44 | content_by_lua " 45 | local dmk = require('resty.danmaku') 46 | dmk.run() 47 | "; 48 | } 49 | 50 | location = /danmaku/stat { 51 | content_by_lua " 52 | local u = require 'resty.danmaku.stat' 53 | require 'resty.danmaku.broadcaster' 54 | ngx.say(u._get_stat()) 55 | "; 56 | } 57 | } 58 | ``` 59 | 60 | [Back to TOC](#table-of-contents) 61 | 62 | 63 | TODO 64 | ==== 65 | 66 | - HTTP pooling support 67 | - TCP protocol support 68 | 69 | [Back to TOC](#table-of-contents) 70 | 71 | 72 | Copyright and License 73 | ===================== 74 | 75 | This module is licensed under the BSD license. 76 | 77 | Copyright (C) 2016, by fffonion . 78 | 79 | All rights reserved. 80 | 81 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 82 | 83 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 84 | 85 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 86 | 87 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 88 | 89 | [Back to TOC](#table-of-contents) 90 | 91 | See Also 92 | ======== 93 | * the ngx_lua module: http://wiki.nginx.org/HttpLuaModule 94 | 95 | [Back to TOC](#table-of-contents) 96 | 97 | -------------------------------------------------------------------------------- /lib/resty/danmaku/broadcaster.lua: -------------------------------------------------------------------------------- 1 | -- broadcaster implementaion 2 | local util = require "resty.danmaku.util" 3 | local stat = require "resty.danmaku.stat" 4 | local ws_server = require "resty.websocket.server" 5 | 6 | local _M = util.new_tab(0, 13) 7 | local mt = { __index = _M } 8 | 9 | -- wake in local wake_time = 5 10 | local WAKE_TIME = 5 11 | 12 | function _M.new(self, opts) 13 | local semaphore = require "ngx.semaphore" 14 | 15 | local _ = setmetatable({ 16 | subscribers = {}, 17 | liveid = opts.liveid, 18 | message_queue = {}, 19 | queue_sema = semaphore.new(), 20 | dying = 0, 21 | subs_count = 0, 22 | gid = util.random_str(16), 23 | last_sent_meta = 0 24 | }, mt) 25 | 26 | util.set_broadcaster(opts.liveid, _) 27 | util.set_broadcaster_gid(opts.liveid, _.gid) 28 | stat._brd_create() 29 | 30 | return _ 31 | end 32 | 33 | function _M.run(self) 34 | self.dying = 0 35 | self.last_sent_meta = ngx.time() 36 | while self.subs_count > 0 or self.dying == 0 or self.dying > os.time() do 37 | if self.subs_count == 0 and self.dying == 0 then 38 | -- set up countdown in 60s 39 | self.dying = os.time() + 60 40 | ngx.log(ngx.NOTICE, "broadcaster will exit in 60s if no one enters, liveid ", self.liveid) 41 | --elseif dying < os.time() then 42 | -- break 43 | end 44 | -- wait on semaphore with at least 1s 45 | self.queue_sema:wait(WAKE_TIME) 46 | -- time.sleep(1) 47 | -- copy to temp table 48 | local deleted = {} 49 | for msgid, msg in pairs(self.message_queue) do 50 | deleted[msgid] = msg 51 | self.message_queue[msgid] = nil 52 | end 53 | -- determine if should send meta 54 | local meta, metaid 55 | if ngx.time() - self.last_sent_meta > WAKE_TIME * 3 then 56 | meta, meta_id = _M.get_meta(self) 57 | ngx.log(ngx.NOTICE, "will send meta, room ", self.liveid, ", msg ", meta, ", msgid ", meta_id) 58 | self.last_sent_meta = ngx.time() 59 | end 60 | for uid, _ in pairs(self.subscribers) do 61 | local _sb = util.get_subscriber(uid) 62 | for msgid, msg in pairs(deleted) do 63 | -- if uid == msg.uid then continue end 64 | ngx.log(ngx.NOTICE, "send ", msg, " to ", uid, _, "---", tostring(_sb)) 65 | _sb:push(msgid, msg) 66 | end 67 | if meta_id then 68 | _sb:push(meta_id, meta) 69 | end 70 | 71 | end 72 | end 73 | stat._brd_destory() 74 | _M.cleanup(self) 75 | end 76 | 77 | function _M.queue_msg(self, dt) 78 | local _ = util.random_str(32) 79 | self.message_queue[_] = dt 80 | self.queue_sema:post() 81 | end 82 | 83 | 84 | function _M.get_meta(self, pure) 85 | local json = require("cjson") 86 | local msgid = util.random_str(16) 87 | local rds = util.get_redis() 88 | local _r1, err = rds:get("onair_" .. self.liveid) 89 | local _r2, err = rds:hmget("live_" .. self.liveid, "title", "owner") 90 | local tb = { 91 | ["type"] = "meta", 92 | ["msgid"] = msgid, 93 | ["meta"] = { 94 | ["audience"] = self.subs_count, 95 | ["title"] = _r2[1], 96 | ["onair"] = _r1, 97 | ["owner"] = _r2[2] 98 | } 99 | } 100 | util.close_redis(rds) 101 | if pure then 102 | return json.encode(tb.meta) 103 | else 104 | return json.encode(tb), msgid 105 | end 106 | end 107 | 108 | function _M.add_subscriber(self, uid) 109 | if self.subscribers ~= nil then 110 | self.dying = 0 111 | self.subscribers[uid] = 1 112 | end 113 | self.subs_count = self.subs_count + 1 114 | ngx.log(ngx.NOTICE, "added new subscriber ", uid, " now total ", self.subs_count) 115 | -- self.last_sent_meta = time.time() 116 | self.queue_sema:post() 117 | end 118 | 119 | 120 | function _M.del_subscriber(self, uid) 121 | if self.subscribers ~= nil then 122 | self.subscribers[uid] = nil 123 | end 124 | self.subs_count = self.subs_count - 1 125 | ngx.log(ngx.NOTICE, "del subscriber ", uid, " now total ", self.subs_count) 126 | -- self.last_sent_meta = 0 127 | self.queue_sema:post() 128 | end 129 | 130 | 131 | function _M.cleanup(self) 132 | util.set_broadcaster(self.liveid, nil) 133 | self.semaphore = nil 134 | self.message_queue = nil 135 | self.subscribers = nil 136 | ngx.log(ngx.WARN, "broadcaster auto exit, id", self.liveid) 137 | end 138 | 139 | return _M 140 | -------------------------------------------------------------------------------- /lib/resty/danmaku/subscriber.lua: -------------------------------------------------------------------------------- 1 | -- subscriber implementaion 2 | local util = require "resty.danmaku.util" 3 | local stat = require "resty.danmaku.stat" 4 | local ws_server = require "resty.websocket.server" 5 | 6 | local _M = util.new_tab(0, 13) 7 | local mt = { __index = _M } 8 | 9 | function _M.new(self, opts) 10 | local uid = util.random_str(16) 11 | local wb, err = ws_server:new{ 12 | timeout = ngx.var.keep_alive_timeout or 60000, -- in milliseconds 13 | max_payload_len = 65535, 14 | } 15 | 16 | local semaphore = require "ngx.semaphore" 17 | local queue_sema = semaphore.new() 18 | 19 | ngx.log(ngx.NOTICE, "initializing new subscriber ", uid) 20 | 21 | if not wb then 22 | ngx.log(ngx.ERR, "failed to new websocket: ", err) 23 | return ngx.exit(444) 24 | end 25 | 26 | local _ = setmetatable({ 27 | wb = wb, 28 | uid = uid, 29 | msg_queue = {}, 30 | name = opts.name or uid, 31 | liveid = opts.liveid, 32 | closed = false, 33 | queue_sema = queue_sema, 34 | _broadcaster = util.get_broadcaster(opts.liveid) 35 | }, mt) 36 | 37 | util.set_subscriber(uid, _) 38 | _._broadcaster:add_subscriber(uid) 39 | 40 | stat._sub_create() 41 | return _ 42 | end 43 | 44 | function _M.recv_loop(self) 45 | while not self.closed do 46 | -- check broadcast changed or not (after nginx process reload) 47 | if self._broadcaster.gid ~= util.get_broadcaster_gid(self.liveid) then 48 | ngx.log(ngx.ERR, self.uid, ": my broadcaster changed!", self._broadcaster.gid, " != ", util.get_broadcaster_gid(self.liveid)) 49 | break 50 | end 51 | 52 | -- the following is partly based on resty.websocket example 53 | local data, typ, err = self.wb:recv_frame() 54 | 55 | if not data or err or typ == "close" then 56 | break 57 | end 58 | 59 | if typ == "ping" then 60 | -- send a pong frame back: 61 | 62 | local bytes, err = self.wb:send_pong(data) 63 | if not bytes then 64 | ngx.log(ngx.ERR, "[danmaku] failed to send frame: ", err) 65 | return 66 | end 67 | elseif typ == "pong" then 68 | -- just discard the incoming pong frame 69 | 70 | else 71 | local json = require "cjson" 72 | data = json.decode(data) 73 | -- ngx.log(ngx.ERR, "type=", data["type"]) 74 | if data["type"] ~= "heartbeat" then 75 | _M.broadcast(self, data) 76 | stat._sent_dm() 77 | end 78 | -- ngx.sleep(120) 79 | -- ngx.log(ngx.ERR, "received a frame of type ", typ, " and payload ", data) 80 | end 81 | 82 | --[[ 83 | if not bytes then 84 | ngx.log(ngx.ERR, "failed to send a text frame: ", err) 85 | return ngx.exit(444) 86 | end 87 | 88 | bytes, err = self.wb:send_binary("blah blah blah...") 89 | if not bytes then 90 | ngx.log(ngx.ERR, "failed to send a binary frame: ", err) 91 | return ngx.exit(444) 92 | end 93 | ]] 94 | end 95 | 96 | ngx.log(ngx.WARN, "closing ", self.uid, " in room ", self.liveid) 97 | 98 | self.closed = true 99 | self._broadcaster:del_subscriber(self.uid) 100 | 101 | stat._sub_destory() 102 | 103 | local bytes, err = self.wb:send_close(1000, "bye") 104 | if not bytes then 105 | ngx.log(ngx.ERR, "[danmaku] failed to send the close frame: ", err) 106 | return 107 | end 108 | 109 | end 110 | 111 | function _M.send_loop(self) 112 | -- send meta on start 113 | _M.push(self, "0", self._broadcaster:get_meta()) 114 | while not self.closed do 115 | self.queue_sema:wait(5) 116 | for k, v in pairs(self.msg_queue) do 117 | self.wb:send_text(v) 118 | self.msg_queue[k] = nil 119 | end 120 | end 121 | end 122 | 123 | function _M.broadcast(self, tb) 124 | local br = util.get_broadcaster(self.liveid) 125 | local json = require("cjson") 126 | tb.uid = self.uid 127 | tb.msgid = util.random_str(16) 128 | tb.name = self.name 129 | local br = util.get_broadcaster(self.liveid) 130 | if br then 131 | return br:queue_msg(json.encode(tb)) 132 | else 133 | return nil 134 | end 135 | end 136 | 137 | function _M.push(self, msgid, dt) 138 | if self.queue_sema:count() < 1 then -- leave 1 spare resource 139 | self.queue_sema:post() 140 | end 141 | self.msg_queue[msgid] = dt 142 | end 143 | 144 | 145 | return _M 146 | --------------------------------------------------------------------------------