Broadcast.lua 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. --[[
  2. -- Copyright (c) 2013-2016 Marcus Rohrmoser, http://purl.mro.name/recorder
  3. --
  4. -- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
  5. -- associated documentation files (the "Software"), to deal in the Software without restriction,
  6. -- including without limitation the rights to use, copy, modify, merge, publish, distribute,
  7. -- sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
  8. -- furnished to do so, subject to the following conditions:
  9. --
  10. -- The above copyright notice and this permission notice shall be included in all copies or
  11. -- substantial portions of the Software.
  12. --
  13. -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
  14. -- NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
  15. -- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
  16. -- OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
  17. -- CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  18. --
  19. -- MIT License http://opensource.org/licenses/MIT
  20. ]]
  21. local function meta_key_to_lua(k)
  22. return k:gsub('%.', '_')
  23. end
  24. function string:to_filename()
  25. local replace = {
  26. ['/'] = '-',
  27. ["\t"] = ' ',
  28. ["\n"] = ' ',
  29. }
  30. local escape = {
  31. }
  32. local subf = function(s)
  33. return replace[s] or escape[s] or s
  34. end
  35. return self:gsub('.', subf):gsub('–', '-')
  36. end
  37. -------------------------------------------------------------------------------
  38. -- Broadcast ------------------------------------------------------------------
  39. -------------------------------------------------------------------------------
  40. require'Station'
  41. require'Podcast'
  42. require'Enclosure'
  43. -- http://nova-fusion.com/2011/06/30/lua-metatables-tutorial/
  44. -- http://lua-users.org/wiki/LuaClassesWithMetatable
  45. Broadcast = {} -- methods table
  46. Broadcast_mt = { __index = Broadcast } -- metatable
  47. function Broadcast_mt.__eq(a,b)
  48. return a.id == b.id
  49. end
  50. -- compare acc. time, ignore dst switch.
  51. function Broadcast_mt.__le(a,b)
  52. if a.year < b.year then return true
  53. elseif a.year > b.year then return false
  54. elseif a.month < b.month then return true
  55. elseif a.month > b.month then return false
  56. elseif a.day < b.day then return true
  57. elseif a.day > b.day then return false
  58. elseif a.hour < b.hour then return true
  59. elseif a.hour > b.hour then return false
  60. elseif a.min < b.min then return true
  61. elseif a.min > b.min then return false
  62. elseif a.sec < b.sec then return true
  63. elseif a.sec > b.sec then return false
  64. else return a.id <= b.id end
  65. end
  66. function Broadcast_mt.__lt(a,b)
  67. return a <= b and not (b <= a)
  68. end
  69. function Broadcast_mt.__tostring(self)
  70. return self.id
  71. end
  72. local function factory(ret)
  73. local file = assert(ret._title):to_filename()
  74. local t = os.time(ret)
  75. ret.dir = table.concat{assert(ret._station).id, '/', os.date('%Y/%m/%d', t)}
  76. ret.id = table.concat{ret.dir, '/', os.date('%H%M', t), ' ', file}
  77. return setmetatable( ret, Broadcast_mt )
  78. end
  79. function Broadcast.from_meta(meta)
  80. local pbmi = {
  81. DC_scheme = assert( meta.DC_scheme ),
  82. DC_language = assert( meta.DC_language ),
  83. DC_title = assert( meta.DC_title ),
  84. DC_title_series = meta.DC_title_series,
  85. DC_title_episode = meta.DC_title_episode,
  86. DC_subject = meta.DC_subject,
  87. DC_format_timestart = assert( meta.DC_format_timestart ),
  88. DC_format_timeend = assert( meta.DC_format_timeend ),
  89. DC_format_duration = assert( meta.DC_format_duration ),
  90. DC_image = meta.DC_image,
  91. DC_description = assert( meta.DC_description ),
  92. DC_author = meta.DC_author,
  93. DC_creator = meta.DC_creator,
  94. DC_publisher = meta.DC_publisher,
  95. DC_copyright = meta.DC_copyright,
  96. DC_source = assert( meta.DC_source ),
  97. }
  98. local ret = os.date('*t', assert(parse_iso8601(meta.DC_format_timestart, 'missing key \'DC_format_timestart\'')))
  99. ret._station = assert(Station.from_id(meta.station), 'missing key \'station\'')
  100. ret._dtend = assert(parse_iso8601(meta.DC_format_timeend, 'missing key \'DC_format_timeend\''))
  101. ret._title = assert(meta.title)
  102. ret._pbmi = pbmi
  103. return factory(ret)
  104. end
  105. function Broadcast.from_id(f)
  106. local ok,_,station,year,month,day,hour,min,title = f:find('([^/]+)/(%d%d%d%d)/(%d%d)/(%d%d)/(%d%d)(%d%d)%s(.+)$')
  107. if not ok then return nil end
  108. local ret = os.date('*t', os.time{year=year,month=month,day=day,hour=hour,min=min})
  109. ret._station = assert(Station.from_id(station), 'missing key \'station\': ' .. f)
  110. ret._title = assert(title, 'missing title')
  111. return factory(ret)
  112. end
  113. function Broadcast.from_file(f)
  114. -- strip prefix, keep id
  115. local ok,_,id,ext = f:find('([^/]+/%d%d%d%d/%d%d/%d%d/%d%d%d%d .+)$')
  116. if not ok then return nil end
  117. -- strip extension
  118. local ok,_,t,ext = id:find('(.+)(%.[^%.]+)$')
  119. if ok and ('.xml' == ext or '.json' == ext) then id = t end
  120. return Broadcast.from_id(id)
  121. end
  122. function Broadcast:modified()
  123. return lfs.attributes(self:filename('xml'), 'modification')
  124. end
  125. function Broadcast:title()
  126. return assert(self._title)
  127. end
  128. function Broadcast:station()
  129. return assert(self._station)
  130. end
  131. function Broadcast:dtstart()
  132. if not self._dtstart then
  133. self._dtstart = assert(parse_iso8601(self:pbmi().DC_format_timestart))
  134. end
  135. return self._dtstart
  136. end
  137. function Broadcast:dtend()
  138. if not self._dtend then
  139. self._dtend = assert(parse_iso8601(self:pbmi().DC_format_timeend))
  140. end
  141. return self._dtend
  142. end
  143. function Broadcast:is_past(now)
  144. if now == nil then now = os.time() end
  145. if os.time(self) < now then
  146. -- already started, but still running?
  147. if self:dtend() < now then
  148. -- io.stderr:write('I\'m past: ', self.id, "\n")
  149. return true
  150. end
  151. end
  152. return false
  153. end
  154. -- return table w. <meta> plus xml source
  155. -- TODO: sanity check found meta!
  156. local function broadcast_meta_from_xml(xml_file)
  157. local metas,xml_old,file = {},nil,io.open(xml_file, 'r')
  158. if file == nil then return nil,nil,xml_file end
  159. local xml_old = file:read('*a')
  160. file:close()
  161. for v,k in xml_old:gmatch('<meta%s+content=\'([^\']*)\'%s+name=\'(DC%.[^\']+)\'%s*/?>') do
  162. local k,v = k:unescape_xml_text(),v:unescape_xml_text()
  163. metas[ meta_key_to_lua(k) ] = v
  164. end
  165. return metas,xml_old,xml_file
  166. end
  167. function Broadcast:filename(state)
  168. local t = {'stations', '/', self.id}
  169. if state then
  170. table.insert(t, '.')
  171. table.insert(t, state)
  172. end
  173. return table.concat(t)
  174. end
  175. function Broadcast:url(type_)
  176. return table.concat{Recorder.base_url(), self:filename(type_)}:escape_url()
  177. end
  178. -- accessor to Dublin Core PBMI http://dcpapers.dublincore.org/pubs/article/view/749
  179. function Broadcast:pbmi()
  180. if not self._pbmi then
  181. self._pbmi,_,file = broadcast_meta_from_xml(self:filename('xml'))
  182. assert(self._pbmi,'Couldn\'t load pbmi from ' .. file)
  183. end
  184. return assert(self._pbmi, 'lazy load failure.')
  185. end
  186. function Broadcast:enclosure()
  187. if not self._enclosure then
  188. self._enclosure = Enclosure.from_broadcast(self)
  189. end
  190. return self._enclosure
  191. end
  192. function Broadcast:add_podcast(pc)
  193. self:podcasts()[pc.id] = pc
  194. end
  195. function Broadcast:remove_podcast(pc)
  196. self:podcasts()[pc.id] = nil
  197. pc:remove_broadcast(self)
  198. end
  199. function Broadcast:podcasts()
  200. if not self._podcasts then
  201. local ret = {}
  202. for pi_id,pc in pairs(Podcast.each()) do
  203. if pc:contains_broadcast(self) then
  204. ret[pi_id] = pc
  205. end
  206. -- check presence ?
  207. -- evtl. check match ?
  208. -- add to podcast ?
  209. -- add to list ?
  210. end
  211. self._podcasts = ret
  212. end
  213. return self._podcasts
  214. end
  215. function Broadcast:match_podcasts()
  216. for pc_id,pc in pairs( Podcast.each() ) do
  217. local ok,match = pcall(assert(pc.match, 'match'), assert(self:pbmi(), 'pbmi'))
  218. if ok and match then
  219. self:podcasts()[pc_id] = pc
  220. end
  221. end
  222. end
  223. -- find first one smaller or equal dtstart
  224. function Broadcast:prev_sibling()
  225. return self:station():broadcast_now(self:dtstart()-1,true,false)
  226. end
  227. -- find first one bigger or equal dtend
  228. function Broadcast:next_sibling()
  229. return self:station():broadcast_now(self:dtend(),true,true)
  230. end
  231. function Broadcast:monopolize(dry_run)
  232. local callb = function( path )
  233. local other = Broadcast.from_file( path )
  234. if other ~= nil and other ~= self then other:remove(dry_run) end
  235. return false
  236. end
  237. lfs.files_between(table.concat({'stations',self:station().id},'/'), self:dtstart(), self:dtend()-0.1, callb, true)
  238. end
  239. function Broadcast:log_change(msg)
  240. io.stderr:write(string.format("%-7s %s\n",msg,self.id))
  241. if 'unchang' == msg then return end
  242. local f,_ = io.open(table.concat({'stations','modified.ttl'},'/'), 'a+')
  243. if f then
  244. f:write('<', self.id:escape_url(), '> <http://purl.org/dc/terms/modified> "', os.date('!%FT%TZ'), "\" .\n")
  245. f:close()
  246. end
  247. f,_ = io.open(table.concat({'stations',self:station().id,'modified.ttl'},'/'), 'a+')
  248. if f then
  249. f:write('<../', self.id:escape_url(), '> <http://purl.org/dc/terms/modified> "', os.date('!%FT%TZ'), "\" .\n")
  250. f:close()
  251. end
  252. end
  253. function Broadcast:remove(dry_run)
  254. self:log_change('delete')
  255. if dry_run then return end
  256. self:enclosure():unschedule()
  257. for _,pc in pairs(self:podcasts()) do
  258. pc:remove_broadcast(self)
  259. end
  260. io.write_if_changed(self:filename('json'), nil)
  261. io.write_if_changed(self:filename('xml'), nil)
  262. end
  263. function Broadcast:to_xml(xml)
  264. -- TODO check time overlaps?
  265. if not xml then xml = {} end
  266. table.insert( xml, '<!-- unorthodox relative namespace to enable http://www.w3.org/TR/grddl-tests/#sq2 without a central server -->' )
  267. table.insert( xml, '<broadcast xml:lang="de" xmlns="../../../../../assets/2013/radio-pi.rdf">' )
  268. local row = {' ', '<meta content=\'', self.id:escape_xml_attribute(), '\' name=\'', 'DC.identifier', '\'/>'}
  269. table.insert( xml, table.concat(row) )
  270. for _,k in ipairs({
  271. 'DC.scheme', 'DC.language', 'DC.title', 'DC.title.series', 'DC.title.episode', 'DC.subject',
  272. 'DC.format.timestart', 'DC.format.timeend', 'DC.format.duration', 'DC.image',
  273. 'DC.description', 'DC.author', 'DC.publisher', 'DC.creator', 'DC.copyright', 'DC.source',
  274. }) do
  275. local v = self:pbmi()[ meta_key_to_lua(k) ]
  276. if v then
  277. local row = {' ', '<meta content=\'', v:escape_xml_attribute(), '\' name=\'', k, '\'/>'}
  278. table.insert( xml, table.concat(row) )
  279. end
  280. end
  281. table.insert( xml, '</broadcast>' )
  282. return table.concat(xml,"\n")
  283. end
  284. function Broadcast:save_xml()
  285. -- TODO check time overlaps?
  286. local xml = {
  287. '<?xml version="1.0" encoding="UTF-8"?>',
  288. '<?xml-stylesheet type="text/xsl" href="../../../app/broadcast2html.xslt"?>',
  289. }
  290. self:to_xml(xml)
  291. return io.write_if_changed(self:filename('xml'), table.concat(xml,"\n"))
  292. end
  293. function Broadcast:save_podcast_json()
  294. -- io.stderr:write('to_podcast_json()', "\n")
  295. local pc_ids = {}
  296. for pc_id,pc in pairs( self:podcasts() ) do
  297. pc:add_broadcast(self)
  298. table.insert(pc_ids, pc.id)
  299. end
  300. local json = nil
  301. if #pc_ids > 0 then
  302. json = table.concat{'{ "podcasts":[{"name":"', table.concat(pc_ids,'"},{"name":"'), '"}] }'}
  303. end
  304. return io.write_if_changed(self:filename('json'), json)
  305. end
  306. function Broadcast:save_schedule()
  307. for _,_ in pairs(self:podcasts()) do
  308. -- no way to tell count of a hash - so we start to iterate and return after first
  309. return self:enclosure():schedule()
  310. end
  311. return self:enclosure():unschedule()
  312. end
  313. function Broadcast:save()
  314. self:monopolize()
  315. -- broadcast xml
  316. local file,msg,err = self:save_xml()
  317. self:log_change(msg)
  318. -- podcast membership
  319. for _,pc in pairs(self:podcasts()) do
  320. pc:add_broadcast(self)
  321. end
  322. self:save_podcast_json()
  323. -- schedule
  324. local at_job,cmd = self:save_schedule()
  325. -- if at_job then io.stderr:write('at job ', at_job, ' ', cmd, "\n") end
  326. return file,msg,err
  327. end