Compare commits

...

4 commits

11 changed files with 334 additions and 136 deletions

26
LICENSE Normal file
View file

@ -0,0 +1,26 @@
Copyright (c) The Regents of the University of California.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. 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.
3. Neither the name of the University nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE REGENTS 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 REGENTS 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.

View file

@ -2,51 +2,70 @@
# FOSDEM Recorder
A tool to schedule FOSDEM recordings and help you record the 5+ talks you
wanted to attend at the same time. Only for the impatient who can't wait for
the official videos to be made available.
wanted to attend at the same time.
Only for the impatient who can't wait for the official videos to be made
available (usually this happens a few days after the event).
## Installation
Install project dependencies
If you have Crystal 1.7+ installed on your system:
$ bundle install
shards install
shards build
sudo cp bin/fosdem-recorder /usr/local/bin/fosdem-recorder
If you don't have crystal, you can also build it with docker:
docker run --name fosdem-recorder \
-v $(pwd):/app crystallang/crystal:1.7.1 \
sh -c "cd /app && shards install && shards build"
docker cp fosdem-recorder:/app/bin/fosdem-recorder fosdem-recorder
docker rm fosdem-recorder
mv fosdem-recorder /usr/local/bin/fosdem-recorder
## Usage
Get information about given URL
### Get information about given URL
$ fosdem_recorder info URL
Schedule video download for given URL
$ fosdem_recorder download URL
Real example
Syntax:
```shell-session
$ bundle exec fosdem-recorder info https://fosdem.org/2021/schedule/event/sca_weclome/
* title = FOSDEM 2021 - Software Composition Analysis Devroom Welcome
* start = 14:00
* stop = 14:05
* diff = 00:05:00
* url = https://stream.fosdem.org/dcomposition.m3u8
$ bundle exec fosdem-recorder info https://fosdem.org/2021/schedule/event/sca_weclome/
[... schedules the download with 'at'... ]
[... downloads the file with 'ffmpeg'... ]
[... a MP4 file will be created once the video is downloaded ...]
$ fosdem-recorder info EVENT_URL
```
Real example:
## Development
```shell-session
$ fosdem-recorder info https://fosdem.org/2023/schedule/event/nasa/
Loading data from https://fosdem.org/2023/schedule/event/nasa/
FOSDEM 2023 - Open Source Software at NASA
* event start = 2023-02-05 17:00:00 +01:00
* event stop = 2023-02-05 17:50:00 +01:00
* event length = 00:51:00 (from now: 01:20:00)
* stream url = https://stream.fosdem.org/janson.m3u8
```
After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
### Schedule video download for given URL
Syntax:
```shell-session
$ fosdem-recorder download EVENT_URL
```
Real example:
```shell-session
$ fosdem-recorder download https://fosdem.org/2023/schedule/event/nasa/
Loading data from https://fosdem.org/2023/schedule/event/nasa/
Command: echo ffmpeg -i https://stream.fosdem.org/janson.m3u8 -c copy -bsf:a aac_adtstoasc -movflags frag_keyframe+empty_moov+default_base_moof+faststart -t 01:19:00 "FOSDEM-2023-Open-Source-Software-at-NASA.mp4" | at 17:00 2023-02-05
warning: commands will be executed using /bin/sh
job 635 at Sun Feb 5 17:00:00 2023
```
## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/glenux/fosdem-recorder.
Send bug reports and patches by email to <opensource@glenux.net>

View file

@ -2,7 +2,7 @@ version: 2.0
shards:
lexbor:
git: https://github.com/kostya/lexbor.git
version: 3.0.2
version: 3.0.4
mechanize:
git: https://github.com/kanezoh/mechanize.cr.git

View file

@ -1,6 +1,11 @@
require "option_parser"
require "./duration"
require "./meta_data"
require "./controllers/info_controller"
require "./controllers/download_controller"
# Cli part of FosdemRecorder
module FosdemRecorder
# Fosdem Cli - Download and cut streams video
@ -22,26 +27,7 @@ module FosdemRecorder
Cli.new(args)
end
private def info(url)
_validate_url(url)
meta = _get_meta(url)
puts meta[:title].colorize.fore(:green)
puts "* event start = #{meta[:event_start]}"
puts "* event stop = #{meta[:event_stop]}"
puts "* event length = #{meta[:duration_str]}"
puts "* stream url = #{meta[:stream_url]}"
end
private def download(url)
_validate_url(url)
meta = _get_meta(url)
localtime = meta[:event_start].to_local
timeformat = localtime.to_s("%H:%M %Y-%m-%d")
cmd = "echo ffmpeg -i #{meta[:stream_url]} -c copy -t #{meta[:duration_str]} \"#{meta[:title_sane]}.mp4\" | at #{timeformat}"
puts "Command: #{cmd}".colorize.fore(:yellow)
system cmd
end
private def parse(args)
commands = [] of Proc(String, Nil)
@ -49,12 +35,16 @@ module FosdemRecorder
OptionParser.parse(args) do |opts|
opts.banner = "Usage: fosdem-recorder [subcommand] [arguments]"
# opts.on("update-cache", "Fetch schedule information") do
# commands << ->InfoController.process(String)
# end
opts.on("info", "Get information about URL") do
commands << ->info(String)
commands << ->InfoController.process(String)
end
opts.on("download", "Download conference described at URL") do
commands << ->download(String)
commands << ->DownloadController.process(String)
end
opts.on("-h", "--help", "Shows this help") do
@ -69,91 +59,5 @@ module FosdemRecorder
end
end
private def _validate_url(url)
return if url =~ %r{^https://fosdem.org/\d+/schedule/event/.*}
if url =~ %r{^https://fosdem.org/.*}
STDERR.puts "ERROR: not a schedule page. URL must contain .../schedule/event/..."
exit 1
end
STDERR.puts "ERROR: not a fosdem stream. URL must start with https://fosdem.org/..."
exit 1
end
private def _get_meta(url)
puts "Loading data from #{url}".colorize.fore(:yellow)
mechanize = Mechanize.new
begin
page = mechanize.get(url)
rescue ex : Socket::Addrinfo::Error
STDERR.puts "ERROR: #{ex.message}"
exit 1
end
# body_class = page.at('body').attr('class')
# if body_class != 'schedule-event'
# STDERR.puts "ERROR: Not an event schedule page!"
# exit 1
# end
# puts body_class
title = page.title
title_sane =
title
.gsub(/[^a-zA-Z0-9]/, "-")
.gsub(/--*/, "-")
.gsub(/-$/, "")
.gsub(/^-/, "")
play_start_str =
page
.css(".side-box .icon-play").first.parent
.try &.css(".value-title").first["title"].strip
play_start_str = "" if play_start_str.nil?
puts "PLAY_START = #{play_start_str}"
location = Time::Location.load("Europe/Brussels")
# play_start = Time.parse(play_start_str, "%H:%S", location)
play_start = Time.parse_rfc3339(play_start_str) #, location)
play_stop_str =
page
.css(".side-box .icon-stop").first.parent
.try &.css(".value-title").first["title"].strip
play_stop_str = "" if play_stop_str.nil?
# play_stop = Time.parse(play_stop_str, "%H:%S", location)
play_stop = Time.parse_rfc3339(play_stop_str)
duration = (play_stop - play_start).to_i / 3600
duration_h = duration.to_i
duration_m = ((duration - duration_h) * 60 + 1).to_i
duration_str = sprintf("%02d:%02d:00", { duration_h, duration_m })
stream_page =
page
.links
.select { |link| link.href =~ /live.fosdem.org/ }
.first
.href
stream_url =
stream_page
.gsub(%r{.*watch/}, "https://stream.fosdem.org/")
.gsub(/$/, ".m3u8")
{
title: title,
title_sane: title_sane,
stream_url: stream_url,
event_start: play_start,
event_stop: play_stop,
event_start_str: play_start_str,
event_stop_str: play_stop_str,
duration_str: duration_str
}
end
end
end

View file

@ -0,0 +1,6 @@
module FosdemRecorder
class BaseController
end
end

View file

@ -0,0 +1,58 @@
require "./base_controller"
require "../utils/url_validator"
module FosdemRecorder
class DownloadController < BaseController
def self.process(url)
UrlValidator.validate_event! url
meta = EventPage.get_meta(url)
now = Time.local #Time::Location.load("Europe/Brussels")
event_start = meta.event_start
raise "Event start is missing!" if event_start.nil?
event_stop = meta.event_stop
raise "Event stop is missing!" if event_stop.nil?
event_start_localtime = event_start.to_local
postpone_download = (event_start_localtime >= now)
# compute remaining duration when needed
duration = Duration.new(start: event_start, stop: event_stop)
remaining_duration = duration.from_now
timeformat = event_start_localtime.to_s("%H:%M %Y-%m-%d")
# FIXME: mark the file as partial
cmd = [
"ffmpeg",
# First the stream URL
"-i", meta.stream_url,
# Then the codec (simple copy)
"-c", "copy",
# Fix malformed AAC bitstream when detected
"-bsf:a", "aac_adtstoasc",
# Make the stream playable as we download it
"-movflags", "frag_keyframe+empty_moov+default_base_moof+faststart",
# Set record duration
"-t", remaining_duration.to_s,
# Set output filename
"\"#{meta.title_sanitized}.mp4\""
].join(" ")
if postpone_download
cmd = "echo #{cmd} | at #{timeformat}"
else
cmd = "echo #{cmd} | at now"
end
puts "Command: #{cmd}".colorize.fore(:yellow)
system cmd
end
end
end

View file

@ -0,0 +1,31 @@
require "./base_controller"
require "../utils/url_validator"
require "../event_page"
module FosdemRecorder
class InfoController < BaseController
def self.process(url)
UrlValidator.validate_event! url
meta = EventPage.get_meta(url)
puts meta.title.colorize.fore(:green)
start = meta.event_start
stop = meta.event_stop
puts "* event start = #{start}"
puts "* event stop = #{stop}"
if !start.nil? && !stop.nil?
duration = Duration.new(start: start, stop: stop)
duration_remaining = Duration.new(start: start, stop: stop).from_now
puts "* event length = #{duration.to_s} (from now: #{duration_remaining.to_s})"
else
puts "* event length = (none)"
end
puts "* stream url = #{meta.stream_url ? meta.stream_url : "(none)"}"
end
end
end

37
src/duration.cr Normal file
View file

@ -0,0 +1,37 @@
module FosdemRecorder
class Duration
getter start : Time
getter stop : Time
def initialize(@start, @stop)
end
def from_now()
Duration.new(start: Time.local, stop: self.stop)
end
def value()
duration = (self.stop - self.start).to_i
end
def past?
Time.local > self.stop
end
def future?
Time.local < self.start
end
def present?
(Time.local >= self.start) && (Time.local <= self.stop)
end
def to_s()
duration = self.value / 3600
duration_h = duration.to_i
duration_m = ((duration - duration_h) * 60 + 1).to_i
duration_str = sprintf("%02d:%02d:00", { duration_h, duration_m })
end
end
end

73
src/event_page.cr Normal file
View file

@ -0,0 +1,73 @@
module FosdemRecorder
# Fosdem Cli - Download and cut streams video
class EventPage
def self.get_meta(url)
puts "Loading data from #{url}".colorize.fore(:yellow)
mechanize = Mechanize.new
begin
page = mechanize.get(url)
rescue ex : Socket::Addrinfo::Error
STDERR.puts "ERROR: #{ex.message}"
exit 1
end
# body_class = page.at('body').attr('class')
# if body_class != 'schedule-event'
# STDERR.puts "ERROR: Not an event schedule page!"
# exit 1
# end
# puts body_class
title = page.title
title_sanitized =
title
.gsub(/[^a-zA-Z0-9]/, "-")
.gsub(/--*/, "-")
.gsub(/-$/, "")
.gsub(/^-/, "")
play_start_str =
page
.css(".side-box .icon-play").first.parent
.try &.css(".value-title").first["title"].strip
play_start_str = "" if play_start_str.nil?
location = Time::Location.load("Europe/Brussels")
# play_start = Time.parse(play_start_str, "%H:%S", location)
play_start = Time.parse_rfc3339(play_start_str) #, location)
play_stop_str =
page
.css(".side-box .icon-stop").first.parent
.try &.css(".value-title").first["title"].strip
play_stop_str = "" if play_stop_str.nil?
# play_stop = Time.parse(play_stop_str, "%H:%S", location)
play_stop = Time.parse_rfc3339(play_stop_str)
stream_page =
page
.links
.select { |link| link.href =~ /live.fosdem.org/ }
.first?
.try &.href
stream_url =
stream_page
.try &.gsub(%r{.*watch/}, "https://stream.fosdem.org/")
.gsub(/$/, ".m3u8")
meta = MetaData.new(
title: title,
title_sanitized: title_sanitized,
stream_url: stream_url,
event_start: play_start,
event_stop: play_stop,
)
end
end
end

19
src/meta_data.cr Normal file
View file

@ -0,0 +1,19 @@
module FosdemRecorder
class MetaData
getter title : String?
getter title_sanitized : String?
getter stream_url : String?
getter event_start : Time?
getter event_stop : Time?
def initialize(
@title,
@title_sanitized,
@stream_url,
@event_start,
@event_stop)
end
end
end

View file

@ -0,0 +1,25 @@
module FosdemRecorder
class UrlValidator
class PageIsNotFosdemError < Exception
def message
"Not a fosdem stream. URL must start with https://fosdem.org/..."
end
end
class PageIsNotScheduleError < Exception
def message
"Not a schedule page. URL must contain .../schedule/event/..."
end
end
def self.validate_event!(url) : Nil
return if url =~ %r{^https://fosdem.org/\d+/schedule/event/.*}
raise PageIsNotScheduleError.new if url =~ %r{^https://fosdem.org/.*}
raise PageIsNotFosdemError.new
nil
end
end
end