simple_pvr 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.rspec +1 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +133 -0
- data/LICENSE.txt +13 -0
- data/README.md +169 -0
- data/Rakefile +16 -0
- data/bin/pvr_server +10 -0
- data/bin/pvr_xmltv +18 -0
- data/features/channel_overview.feature +48 -0
- data/features/programme_search.feature +20 -0
- data/features/scheduling.feature +112 -0
- data/features/step_definitions/pvr_steps.rb +104 -0
- data/features/step_definitions/web_steps.rb +219 -0
- data/features/support/env.rb +28 -0
- data/features/support/paths.rb +17 -0
- data/features/week_overview.feature +39 -0
- data/lib/simple_pvr/ffmpeg.rb +22 -0
- data/lib/simple_pvr/hdhomerun.rb +103 -0
- data/lib/simple_pvr/hdhomerun_save.sh +10 -0
- data/lib/simple_pvr/model/channel.rb +72 -0
- data/lib/simple_pvr/model/database_initializer.rb +36 -0
- data/lib/simple_pvr/model/programme.rb +58 -0
- data/lib/simple_pvr/model/recording.rb +45 -0
- data/lib/simple_pvr/model/schedule.rb +33 -0
- data/lib/simple_pvr/pvr_initializer.rb +47 -0
- data/lib/simple_pvr/pvr_logger.rb +14 -0
- data/lib/simple_pvr/recorder.rb +25 -0
- data/lib/simple_pvr/recording_manager.rb +101 -0
- data/lib/simple_pvr/recording_planner.rb +72 -0
- data/lib/simple_pvr/scheduler.rb +124 -0
- data/lib/simple_pvr/server/app_controller.rb +13 -0
- data/lib/simple_pvr/server/base_controller.rb +94 -0
- data/lib/simple_pvr/server/channels_controller.rb +68 -0
- data/lib/simple_pvr/server/config.ru +8 -0
- data/lib/simple_pvr/server/programmes_controller.rb +46 -0
- data/lib/simple_pvr/server/rack_maps.rb +7 -0
- data/lib/simple_pvr/server/schedules_controller.rb +71 -0
- data/lib/simple_pvr/server/shows_controller.rb +63 -0
- data/lib/simple_pvr/server/status_controller.rb +11 -0
- data/lib/simple_pvr/server/upcoming_recordings_controller.rb +18 -0
- data/lib/simple_pvr/version.rb +3 -0
- data/lib/simple_pvr/xmltv_reader.rb +83 -0
- data/lib/simple_pvr.rb +22 -0
- data/public/css/bootstrap-responsive.min.css +9 -0
- data/public/css/bootstrap.min.css +9 -0
- data/public/css/simplepvr.css +11 -0
- data/public/img/glyphicons-halflings-white.png +0 -0
- data/public/img/glyphicons-halflings.png +0 -0
- data/public/index.html +55 -0
- data/public/js/angular/angular-resource.min.js +10 -0
- data/public/js/angular/angular.min.js +157 -0
- data/public/js/app.js +145 -0
- data/public/js/bootstrap/bootstrap.min.js +6 -0
- data/public/js/controllers.js +156 -0
- data/public/js/services.js +27 -0
- data/public/partials/about.html +5 -0
- data/public/partials/channels.html +41 -0
- data/public/partials/programme.html +20 -0
- data/public/partials/programmeListing.html +18 -0
- data/public/partials/schedule.html +80 -0
- data/public/partials/schedules.html +44 -0
- data/public/partials/search.html +17 -0
- data/public/partials/show.html +21 -0
- data/public/partials/shows.html +7 -0
- data/public/partials/status.html +6 -0
- data/simple_pvr.gemspec +30 -0
- data/spec/resources/channels.txt +11 -0
- data/spec/resources/programs-without-icon.xmltv +95 -0
- data/spec/resources/programs.xmltv +98 -0
- data/spec/simple_pvr/ffmpeg_spec.rb +26 -0
- data/spec/simple_pvr/hdhomerun_spec.rb +82 -0
- data/spec/simple_pvr/model/channel_spec.rb +114 -0
- data/spec/simple_pvr/model/programme_spec.rb +110 -0
- data/spec/simple_pvr/model/schedule_spec.rb +47 -0
- data/spec/simple_pvr/pvr_initializer_spec.rb +50 -0
- data/spec/simple_pvr/recorder_spec.rb +32 -0
- data/spec/simple_pvr/recording_manager_spec.rb +158 -0
- data/spec/simple_pvr/recording_planner_spec.rb +104 -0
- data/spec/simple_pvr/scheduler_spec.rb +201 -0
- data/spec/simple_pvr/xmltv_reader_spec.rb +49 -0
- data/test/config/jsTestDriver-scenario.conf +10 -0
- data/test/config/jsTestDriver.conf +12 -0
- data/test/config/jstd-scenario-adapter-config.js +6 -0
- data/test/filtersSpec.js +97 -0
- data/test/lib/angular/angular-mocks.js +1719 -0
- data/test/lib/angular/angular-scenario.js +25937 -0
- data/test/lib/angular/jstd-scenario-adapter.js +185 -0
- data/test/lib/angular/version.txt +1 -0
- data/test/lib/jasmine/MIT.LICENSE +20 -0
- data/test/lib/jasmine/index.js +180 -0
- data/test/lib/jasmine/jasmine-html.js +190 -0
- data/test/lib/jasmine/jasmine.css +166 -0
- data/test/lib/jasmine/jasmine.js +2476 -0
- data/test/lib/jasmine/jasmine_favicon.png +0 -0
- data/test/lib/jasmine/version.txt +1 -0
- data/test/lib/jasmine-jstd-adapter/JasmineAdapter.js +196 -0
- data/test/lib/jasmine-jstd-adapter/version.txt +1 -0
- data/test/lib/jstestdriver/JsTestDriver.jar +0 -0
- data/test/lib/jstestdriver/version.txt +1 -0
- data/test/scripts/test-server.sh +14 -0
- data/test/scripts/test.sh +8 -0
- metadata +342 -0
@@ -0,0 +1,219 @@
|
|
1
|
+
# Taken from the cucumber-rails project.
|
2
|
+
# IMPORTANT: This file is generated by cucumber-sinatra - edit at your own peril.
|
3
|
+
# It is recommended to regenerate this file in the future when you upgrade to a
|
4
|
+
# newer version of cucumber-sinatra. Consider adding your own code to a new file
|
5
|
+
# instead of editing this one. Cucumber will automatically load all features/**/*.rb
|
6
|
+
# files.
|
7
|
+
|
8
|
+
require 'uri'
|
9
|
+
require 'cgi'
|
10
|
+
require File.expand_path(File.join(File.dirname(__FILE__), "..", "support", "paths"))
|
11
|
+
|
12
|
+
module WithinHelpers
|
13
|
+
def with_scope(locator)
|
14
|
+
locator ? within(locator) { yield } : yield
|
15
|
+
end
|
16
|
+
end
|
17
|
+
World(WithinHelpers)
|
18
|
+
|
19
|
+
Given /^(?:|I )am on (.+)$/ do |page_name|
|
20
|
+
visit path_to(page_name)
|
21
|
+
end
|
22
|
+
|
23
|
+
When /^(?:|I )go to (.+)$/ do |page_name|
|
24
|
+
visit path_to(page_name)
|
25
|
+
end
|
26
|
+
|
27
|
+
When /^(?:|I )press "([^\"]*)"(?: within "([^\"]*)")?$/ do |button, selector|
|
28
|
+
with_scope(selector) do
|
29
|
+
click_button(button)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
When /^(?:|I )follow "([^\"]*)"(?: within "([^\"]*)")?$/ do |link, selector|
|
34
|
+
with_scope(selector) do
|
35
|
+
click_link(link)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
When /^(?:|I )fill in "([^\"]*)" with "([^\"]*)"(?: within "([^\"]*)")?$/ do |field, value, selector|
|
40
|
+
with_scope(selector) do
|
41
|
+
fill_in(field, :with => value)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
When /^(?:|I )fill in "([^\"]*)" for "([^\"]*)"(?: within "([^\"]*)")?$/ do |value, field, selector|
|
46
|
+
with_scope(selector) do
|
47
|
+
fill_in(field, :with => value)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Use this to fill in an entire form with data from a table. Example:
|
52
|
+
#
|
53
|
+
# When I fill in the following:
|
54
|
+
# | Account Number | 5002 |
|
55
|
+
# | Expiry date | 2009-11-01 |
|
56
|
+
# | Note | Nice guy |
|
57
|
+
# | Wants Email? | |
|
58
|
+
#
|
59
|
+
# TODO: Add support for checkbox, select og option
|
60
|
+
# based on naming conventions.
|
61
|
+
#
|
62
|
+
When /^(?:|I )fill in the following(?: within "([^\"]*)")?:$/ do |selector, fields|
|
63
|
+
with_scope(selector) do
|
64
|
+
fields.rows_hash.each do |name, value|
|
65
|
+
When %{I fill in "#{name}" with "#{value}"}
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
When /^(?:|I )select "([^\"]*)" from "([^\"]*)"(?: within "([^\"]*)")?$/ do |value, field, selector|
|
71
|
+
with_scope(selector) do
|
72
|
+
select(value, :from => field)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
When /^(?:|I )check "([^\"]*)"(?: within "([^\"]*)")?$/ do |field, selector|
|
77
|
+
with_scope(selector) do
|
78
|
+
check(field)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
When /^(?:|I )uncheck "([^\"]*)"(?: within "([^\"]*)")?$/ do |field, selector|
|
83
|
+
with_scope(selector) do
|
84
|
+
uncheck(field)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
When /^(?:|I )choose "([^\"]*)"(?: within "([^\"]*)")?$/ do |field, selector|
|
89
|
+
with_scope(selector) do
|
90
|
+
choose(field)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
When /^(?:|I )attach the file "([^\"]*)" to "([^\"]*)"(?: within "([^\"]*)")?$/ do |path, field, selector|
|
95
|
+
with_scope(selector) do
|
96
|
+
attach_file(field, path)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
Then /^(?:|I )should see JSON:$/ do |expected_json|
|
101
|
+
require 'json'
|
102
|
+
expected = JSON.pretty_generate(JSON.parse(expected_json))
|
103
|
+
actual = JSON.pretty_generate(JSON.parse(response.body))
|
104
|
+
expected.should == actual
|
105
|
+
end
|
106
|
+
|
107
|
+
Then /^(?:|I )should see "([^\"]*)"(?: within "([^\"]*)")?$/ do |text, selector|
|
108
|
+
with_scope(selector) do
|
109
|
+
if page.respond_to? :should
|
110
|
+
page.should have_content(text)
|
111
|
+
else
|
112
|
+
assert page.has_content?(text)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
Then /^(?:|I )should see \/([^\/]*)\/(?: within "([^\"]*)")?$/ do |regexp, selector|
|
118
|
+
regexp = Regexp.new(regexp)
|
119
|
+
with_scope(selector) do
|
120
|
+
if page.respond_to? :should
|
121
|
+
page.should have_xpath('//*', :text => regexp)
|
122
|
+
else
|
123
|
+
assert page.has_xpath?('//*', :text => regexp)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
Then /^(?:|I )should not see "([^\"]*)"(?: within "([^\"]*)")?$/ do |text, selector|
|
129
|
+
with_scope(selector) do
|
130
|
+
if page.respond_to? :should
|
131
|
+
page.should have_no_content(text)
|
132
|
+
else
|
133
|
+
assert page.has_no_content?(text)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
Then /^(?:|I )should not see \/([^\/]*)\/(?: within "([^\"]*)")?$/ do |regexp, selector|
|
139
|
+
regexp = Regexp.new(regexp)
|
140
|
+
with_scope(selector) do
|
141
|
+
if page.respond_to? :should
|
142
|
+
page.should have_no_xpath('//*', :text => regexp)
|
143
|
+
else
|
144
|
+
assert page.has_no_xpath?('//*', :text => regexp)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
Then /^the "([^\"]*)" field(?: within "([^\"]*)")? should contain "([^\"]*)"$/ do |field, selector, value|
|
150
|
+
with_scope(selector) do
|
151
|
+
field = find_field(field)
|
152
|
+
field_value = (field.tag_name == 'textarea') ? field.text : field.value
|
153
|
+
if field_value.respond_to? :should
|
154
|
+
field_value.should =~ /#{value}/
|
155
|
+
else
|
156
|
+
assert_match(/#{value}/, field_value)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
Then /^the "([^\"]*)" field(?: within "([^\"]*)")? should not contain "([^\"]*)"$/ do |field, selector, value|
|
162
|
+
with_scope(selector) do
|
163
|
+
field = find_field(field)
|
164
|
+
field_value = (field.tag_name == 'textarea') ? field.text : field.value
|
165
|
+
if field_value.respond_to? :should_not
|
166
|
+
field_value.should_not =~ /#{value}/
|
167
|
+
else
|
168
|
+
assert_no_match(/#{value}/, field_value)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
Then /^the "([^\"]*)" checkbox(?: within "([^\"]*)")? should be checked$/ do |label, selector|
|
174
|
+
with_scope(selector) do
|
175
|
+
field_checked = find_field(label)['checked']
|
176
|
+
if field_checked.respond_to? :should
|
177
|
+
field_checked.should == 'checked'
|
178
|
+
else
|
179
|
+
assert_equal 'checked', field_checked
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
Then /^the "([^\"]*)" checkbox(?: within "([^\"]*)")? should not be checked$/ do |label, selector|
|
185
|
+
with_scope(selector) do
|
186
|
+
field_checked = find_field(label)['checked']
|
187
|
+
if field_checked.respond_to? :should_not
|
188
|
+
field_checked.should_not == 'checked'
|
189
|
+
else
|
190
|
+
assert_not_equal 'checked', field_checked
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
Then /^(?:|I )should be on (.+)$/ do |page_name|
|
196
|
+
current_path = URI.parse(current_url).path
|
197
|
+
if current_path.respond_to? :should
|
198
|
+
current_path.should == path_to(page_name)
|
199
|
+
else
|
200
|
+
assert_equal path_to(page_name), current_path
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
Then /^(?:|I )should have the following query string:$/ do |expected_pairs|
|
205
|
+
query = URI.parse(current_url).query
|
206
|
+
actual_params = query ? CGI.parse(query) : {}
|
207
|
+
expected_params = {}
|
208
|
+
expected_pairs.rows_hash.each_pair{|k,v| expected_params[k] = v.split(',')}
|
209
|
+
|
210
|
+
if actual_params.respond_to? :should
|
211
|
+
actual_params.should == expected_params
|
212
|
+
else
|
213
|
+
assert_equal expected_params, actual_params
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
Then /^show me the page$/ do
|
218
|
+
save_and_open_page
|
219
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
ENV['RACK_ENV'] = 'test'
|
2
|
+
require 'capybara'
|
3
|
+
require 'capybara/cucumber'
|
4
|
+
require 'rspec'
|
5
|
+
|
6
|
+
require File.join(File.dirname(__FILE__), '../../lib/simple_pvr')
|
7
|
+
|
8
|
+
SimplePvr::PvrInitializer.setup_for_integration_test
|
9
|
+
SimplePvr::RecordingPlanner.read
|
10
|
+
|
11
|
+
Capybara.app = eval "Rack::Builder.new {( " + SimplePvr::PvrInitializer.rack_maps_file + ")}"
|
12
|
+
Capybara.default_driver = :selenium
|
13
|
+
Capybara.default_wait_time = 5
|
14
|
+
Capybara.ignore_hidden_elements = true # AngularJS shows and hides elements all the time, so this is important
|
15
|
+
|
16
|
+
class SimplePvrWorld
|
17
|
+
include Capybara::DSL
|
18
|
+
include RSpec::Expectations
|
19
|
+
include RSpec::Matchers
|
20
|
+
end
|
21
|
+
|
22
|
+
World do
|
23
|
+
SimplePvrWorld.new
|
24
|
+
end
|
25
|
+
|
26
|
+
Before do
|
27
|
+
SimplePvr::Model::DatabaseInitializer.clear
|
28
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module NavigationHelpers
|
2
|
+
def path_to(page_name)
|
3
|
+
case page_name
|
4
|
+
when 'the schedules page'
|
5
|
+
'/'
|
6
|
+
when 'the channel overview page'
|
7
|
+
'/channels'
|
8
|
+
when 'the status page'
|
9
|
+
'/status'
|
10
|
+
else
|
11
|
+
raise "Can't find mapping from \"#{page_name}\" to a path.\n" +
|
12
|
+
"Now, go and add a mapping in #{__FILE__}"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
World(NavigationHelpers)
|
@@ -0,0 +1,39 @@
|
|
1
|
+
Feature: Week overview
|
2
|
+
In order to find out what's on a specific channel
|
3
|
+
As a user
|
4
|
+
I want the weekly schedules for a channel
|
5
|
+
|
6
|
+
Background:
|
7
|
+
Given the following channels:
|
8
|
+
| name |
|
9
|
+
| Channel 1 |
|
10
|
+
| Channel 2 |
|
11
|
+
And the following programmes:
|
12
|
+
| title | subtitle | channel | day |
|
13
|
+
| Bonderøven | Danish documentary | Channel 1 | 1 |
|
14
|
+
| Blood and Bone | American action movie from 2009 | Channel 1 | 2 |
|
15
|
+
| The future is here | American action movie from 2032 | Channel 1 | 8 |
|
16
|
+
| The past is here | American action movie from 1992 | Channel 1 | -1 |
|
17
|
+
| Noddy | Children's programme | Channel 2 | 3 |
|
18
|
+
And I have navigated to the week overview for channel "Channel 1"
|
19
|
+
|
20
|
+
Scenario: I see programmes for the following week on the channel
|
21
|
+
Then I should see "Bonderøven"
|
22
|
+
And I should see "Blood and Bone"
|
23
|
+
But I should not see "The future is here"
|
24
|
+
And I should not see "Noddy"
|
25
|
+
|
26
|
+
Scenario: I can go to the next week
|
27
|
+
When I follow ">>"
|
28
|
+
Then I should see "The future is here"
|
29
|
+
But I should not see "Bonderøven"
|
30
|
+
|
31
|
+
Scenario: I can go to the previous week
|
32
|
+
When I follow "<<"
|
33
|
+
Then I should see "The past is here"
|
34
|
+
But I should not see "Bonderøven"
|
35
|
+
|
36
|
+
Scenario: I can go to the programme description
|
37
|
+
Given I follow "Bonderøven"
|
38
|
+
Then I should see "Danish documentary"
|
39
|
+
And I should see "Episode 23/40"
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module SimplePvr
|
2
|
+
class Ffmpeg
|
3
|
+
def self.create_thumbnail_for(path)
|
4
|
+
thumbnail_file_name = path + '/thumbnail.png'
|
5
|
+
log_file_name = path + '/thumbnail.png.log'
|
6
|
+
|
7
|
+
pid = Process.spawn("ffmpeg -i \"#{path}/stream.ts\" -ss 00:05:00.000 -f image2 -vframes 1 -vf scale=300:ih*300/iw \"#{thumbnail_file_name}\" > \"#{log_file_name}\" 2>&1")
|
8
|
+
Process.detach(pid)
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.transcode_to_webm(path)
|
12
|
+
stream_file_name = path + '/stream.ts'
|
13
|
+
webm_file_name = path + '/stream.webm'
|
14
|
+
log_file_name = path + '/stream.webm.log'
|
15
|
+
|
16
|
+
unless File.exists?(webm_file_name)
|
17
|
+
pid = Process.spawn("ffmpeg -i \"#{stream_file_name}\" -b 64k -vf scale=640:ih*640/iw \"#{webm_file_name}\" > \"#{log_file_name}\" 2>&1")
|
18
|
+
Process.detach(pid)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
module SimplePvr
|
2
|
+
#
|
3
|
+
# Simple fake version of HDHomeRun class. Makes it possible to run integration tests.
|
4
|
+
#
|
5
|
+
class HDHomeRunFake
|
6
|
+
def scan_for_channels; end
|
7
|
+
def start_recording(tuner, frequency, programme_id, directory); end
|
8
|
+
def stop_recording(tuner); end
|
9
|
+
end
|
10
|
+
|
11
|
+
#
|
12
|
+
# Encapsulates all the HDHomeRun-specific functionality. Do not initialize HDHomeRun objects yourself,
|
13
|
+
# but get the current instance through PvrInitializer.
|
14
|
+
#
|
15
|
+
class HDHomeRun
|
16
|
+
attr_reader :device_id
|
17
|
+
|
18
|
+
def initialize
|
19
|
+
@device_id = discover
|
20
|
+
@tuner_pids = [nil, nil]
|
21
|
+
FileUtils.rm(tuner_control_file(0)) if File.exists?(tuner_control_file(0))
|
22
|
+
FileUtils.rm(tuner_control_file(1)) if File.exists?(tuner_control_file(1))
|
23
|
+
end
|
24
|
+
|
25
|
+
def scan_for_channels
|
26
|
+
file_name = 'channels.txt'
|
27
|
+
scan_channels_with_tuner(file_name)
|
28
|
+
Model::Channel.clear
|
29
|
+
read_channels_file(file_name)
|
30
|
+
end
|
31
|
+
|
32
|
+
def start_recording(tuner, frequency, program_id, directory)
|
33
|
+
set_tuner_to_frequency(tuner, frequency)
|
34
|
+
set_tuner_to_program(tuner, program_id)
|
35
|
+
@tuner_pids[tuner] = spawn_recorder_process(tuner, directory)
|
36
|
+
PvrLogger.info("Process ID for recording on tuner #{tuner}: #{@tuner_pids[tuner]}")
|
37
|
+
end
|
38
|
+
|
39
|
+
def stop_recording(tuner)
|
40
|
+
pid = @tuner_pids[tuner]
|
41
|
+
PvrLogger.info("Stopping process #{pid} for tuner #{tuner}")
|
42
|
+
send_control_c_to_process(tuner, pid)
|
43
|
+
reset_tuner_frequency(tuner)
|
44
|
+
@tuner_pids[tuner] = nil
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
def discover
|
49
|
+
IO.popen('hdhomerun_config discover') do |pipe|
|
50
|
+
output = pipe.read
|
51
|
+
return $1 if output =~ /^hdhomerun device (.*) found at .*$/
|
52
|
+
|
53
|
+
raise Exception, "No device found: #{output}"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def scan_channels_with_tuner(file_name)
|
58
|
+
system "hdhomerun_config #{@device_id} scan /tuner0 #{file_name}"
|
59
|
+
end
|
60
|
+
|
61
|
+
def read_channels_file(file_name)
|
62
|
+
channel_frequency = nil
|
63
|
+
|
64
|
+
File.open(file_name, 'r:UTF-8') do |file|
|
65
|
+
file.each_line do |line|
|
66
|
+
if line =~ /^SCANNING: (\d*) .*$/
|
67
|
+
channel_frequency = $1.to_i
|
68
|
+
elsif line =~ /^PROGRAM (\d*): \d* (.*)$/
|
69
|
+
channel_id = $1.to_i
|
70
|
+
channel_name = $2.strip
|
71
|
+
Model::Channel.add(channel_name, channel_frequency, channel_id)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def set_tuner_to_frequency(tuner, frequency)
|
78
|
+
system "hdhomerun_config #{@device_id} set /tuner#{tuner}/channel auto:#{frequency}"
|
79
|
+
end
|
80
|
+
|
81
|
+
def set_tuner_to_program(tuner, program_id)
|
82
|
+
system "hdhomerun_config #{@device_id} set /tuner#{tuner}/program #{program_id}"
|
83
|
+
end
|
84
|
+
|
85
|
+
def spawn_recorder_process(tuner, directory)
|
86
|
+
FileUtils.touch(tuner_control_file(tuner))
|
87
|
+
spawn File.dirname(__FILE__) + "/hdhomerun_save.sh #{@device_id} #{tuner} \"#{directory}/stream.ts\" \"#{directory}/hdhomerun_save.log\" \"#{tuner_control_file(tuner)}\""
|
88
|
+
end
|
89
|
+
|
90
|
+
def reset_tuner_frequency(tuner)
|
91
|
+
system "hdhomerun_config #{@device_id} set /tuner#{tuner}/channel none"
|
92
|
+
end
|
93
|
+
|
94
|
+
def send_control_c_to_process(tuner, pid)
|
95
|
+
FileUtils.rm(tuner_control_file(tuner))
|
96
|
+
Process.wait(pid)
|
97
|
+
end
|
98
|
+
|
99
|
+
def tuner_control_file(tuner)
|
100
|
+
File.dirname(__FILE__) + "/tuner#{tuner}.lock"
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
module SimplePvr
|
2
|
+
module Model
|
3
|
+
class Channel
|
4
|
+
include DataMapper::Resource
|
5
|
+
storage_names[:default] = 'channels'
|
6
|
+
|
7
|
+
property :id, Serial
|
8
|
+
property :name, String
|
9
|
+
property :frequency, Integer
|
10
|
+
property :channel_id, Integer
|
11
|
+
property :icon_url, String, length: 250
|
12
|
+
property :hidden, Boolean, required: true, default: false
|
13
|
+
|
14
|
+
has n, :programmes
|
15
|
+
|
16
|
+
def self.add(name, frequency, id)
|
17
|
+
self.create(
|
18
|
+
name: name,
|
19
|
+
frequency: frequency,
|
20
|
+
channel_id: id
|
21
|
+
)
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.with_current_programmes(id)
|
25
|
+
decorated_with_current_programmes(get(id), Time.now)
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.all_with_current_programmes
|
29
|
+
now = Time.now
|
30
|
+
self.all(order: :name).map {|channel| decorated_with_current_programmes(channel, now) }
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.clear
|
34
|
+
Programme.destroy
|
35
|
+
self.destroy
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.with_name(name)
|
39
|
+
result = self.first(name: name)
|
40
|
+
raise "Unknown channel: '#{name}'" unless result
|
41
|
+
result
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
def self.decorated_with_current_programmes(channel, now)
|
46
|
+
current_programme = current_programme_for(channel, now)
|
47
|
+
number_of_upcoming_programmes = current_programme ? 3 : 4
|
48
|
+
upcoming_programmes = upcoming_programmes_for(channel, number_of_upcoming_programmes, now)
|
49
|
+
{
|
50
|
+
channel: channel,
|
51
|
+
current_programme: current_programme,
|
52
|
+
upcoming_programmes: upcoming_programmes
|
53
|
+
}
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.current_programme_for(channel, now)
|
57
|
+
unless channel.hidden
|
58
|
+
result = Programme.all(:channel => channel, :start_time.lt => now, fields: [:id, :title, :start_time, :duration], order: :start_time.desc, limit: 1)[0]
|
59
|
+
result if result && result.start_time.advance(seconds: result.duration) >= now
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def self.upcoming_programmes_for(channel, limit, now)
|
64
|
+
if channel.hidden
|
65
|
+
[]
|
66
|
+
else
|
67
|
+
Programme.all(:channel => channel, :start_time.gte => now, fields: [:id, :title, :start_time], order: :start_time, limit: limit)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'data_mapper'
|
2
|
+
require 'dm-migrations'
|
3
|
+
require 'active_support/time_with_zone'
|
4
|
+
require File.dirname(__FILE__) + '/channel'
|
5
|
+
require File.dirname(__FILE__) + '/programme'
|
6
|
+
require File.dirname(__FILE__) + '/schedule'
|
7
|
+
require File.dirname(__FILE__) + '/recording'
|
8
|
+
|
9
|
+
DataMapper.finalize
|
10
|
+
|
11
|
+
module SimplePvr
|
12
|
+
module Model
|
13
|
+
class DatabaseInitializer
|
14
|
+
def self.setup(database_file_name = nil)
|
15
|
+
database_file_name ||= Dir.pwd + '/database.sqlite'
|
16
|
+
DataMapper.setup(:default, "sqlite://#{database_file_name}")
|
17
|
+
DataMapper.auto_upgrade!
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.clear
|
21
|
+
Schedule.destroy
|
22
|
+
Programme.destroy
|
23
|
+
Channel.destroy
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.prepare_for_test
|
27
|
+
return if @initialized
|
28
|
+
|
29
|
+
@database_file_name = Dir.pwd + '/spec/resources/test.sqlite'
|
30
|
+
File.delete(@database_file_name) if File.exists?(@database_file_name)
|
31
|
+
self.setup(@database_file_name)
|
32
|
+
@initialized = true
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
module SimplePvr
|
2
|
+
module Model
|
3
|
+
class Programme
|
4
|
+
include DataMapper::Resource
|
5
|
+
storage_names[:default] = 'programmes'
|
6
|
+
|
7
|
+
property :id, Serial
|
8
|
+
property :title, String, index: true, :length => 255
|
9
|
+
property :subtitle, String, :length => 255
|
10
|
+
property :description, Text
|
11
|
+
property :start_time, DateTime
|
12
|
+
property :duration, Integer
|
13
|
+
property :episode_num, String, index: true
|
14
|
+
|
15
|
+
belongs_to :channel
|
16
|
+
|
17
|
+
def self.clear
|
18
|
+
Programme.destroy
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.add(channel, title, subtitle, description, start_time, duration, episode_num)
|
22
|
+
channel.programmes.create(
|
23
|
+
channel: channel,
|
24
|
+
title: title,
|
25
|
+
subtitle: subtitle,
|
26
|
+
description: description,
|
27
|
+
start_time: start_time,
|
28
|
+
duration: duration.to_i,
|
29
|
+
episode_num: episode_num)
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.with_title(title)
|
33
|
+
Programme.all(title: title, order: :start_time)
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.on_channel_with_title(channel, title)
|
37
|
+
Programme.all(channel: channel, title: title, order: :start_time)
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.on_channel_with_title_and_start_time(channel, title, start_time)
|
41
|
+
Programme.all(channel: channel, title: title, start_time: start_time)
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.titles_containing(text)
|
45
|
+
# Maybe there's a smarter way to do substring search than constructing "%#{text}%"? I'd like
|
46
|
+
# a version where the original input is escaped properly. However, this method is not
|
47
|
+
# dangerous, it just means that the user can enter "%" or "_" in the search string for a
|
48
|
+
# wildcard.
|
49
|
+
Programme.all(:title.like => "%#{text}%", fields: [:title], order: :title, limit: 8, unique: true).map {|programme| programme.title }
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.with_title_containing(text)
|
53
|
+
# Same "LIKE" comments as above...
|
54
|
+
Programme.all(:title.like => "%#{text}%", order: :start_time, limit: 20)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module SimplePvr
|
2
|
+
module Model
|
3
|
+
class Recording
|
4
|
+
attr_accessor :channel, :show_name, :start_time, :duration, :programme, :conflicting
|
5
|
+
|
6
|
+
def initialize(channel, show_name, start_time, duration, programme=nil)
|
7
|
+
@channel = channel
|
8
|
+
@show_name = show_name
|
9
|
+
@start_time = start_time
|
10
|
+
@duration = duration
|
11
|
+
@programme = programme
|
12
|
+
end
|
13
|
+
|
14
|
+
def expired?
|
15
|
+
expired_at(Time.now)
|
16
|
+
end
|
17
|
+
|
18
|
+
def expired_at(time)
|
19
|
+
end_time < time
|
20
|
+
end
|
21
|
+
|
22
|
+
def conflicting?
|
23
|
+
conflicting
|
24
|
+
end
|
25
|
+
|
26
|
+
def inspect
|
27
|
+
"'#{@show_name}' from '#{@channel.name}' at '#{@start_time}' with duration #{@duration} and programme #{@programme}"
|
28
|
+
end
|
29
|
+
|
30
|
+
def ==(other)
|
31
|
+
other != nil &&
|
32
|
+
other.channel == @channel &&
|
33
|
+
other.show_name == @show_name &&
|
34
|
+
other.start_time == @start_time &&
|
35
|
+
other.duration == @duration &&
|
36
|
+
other.programme == @programme
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
def end_time
|
41
|
+
@start_time.advance(seconds: duration)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module SimplePvr
|
2
|
+
module Model
|
3
|
+
class Schedule
|
4
|
+
include DataMapper::Resource
|
5
|
+
storage_names[:default] = 'schedules'
|
6
|
+
|
7
|
+
property :id, Serial
|
8
|
+
property :type, Enum[:specification, :exception]
|
9
|
+
property :title, String, :length => 255
|
10
|
+
# If specified (and channel is specified too), this schedule is for a specific
|
11
|
+
# programme at a specific channel at a specific time
|
12
|
+
property :start_time, DateTime
|
13
|
+
property :filter_by_weekday, Boolean
|
14
|
+
property :monday, Boolean
|
15
|
+
property :tuesday, Boolean
|
16
|
+
property :wednesday, Boolean
|
17
|
+
property :thursday, Boolean
|
18
|
+
property :friday, Boolean
|
19
|
+
property :saturday, Boolean
|
20
|
+
property :sunday, Boolean
|
21
|
+
|
22
|
+
belongs_to :channel, required: false
|
23
|
+
|
24
|
+
def self.add_specification(options)
|
25
|
+
Schedule.create(
|
26
|
+
type: :specification,
|
27
|
+
title: options[:title],
|
28
|
+
channel: options[:channel],
|
29
|
+
start_time: options[:start_time])
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|