simple_pvr 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|