active_encode 0.1.1 → 0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/.travis.yml +3 -2
- data/Gemfile +35 -0
- data/README.md +15 -14
- data/Rakefile +5 -10
- data/active_encode.gemspec +8 -5
- data/app/jobs/active_encode/polling_job.rb +20 -0
- data/app/models/active_encode/encode_record.rb +5 -0
- data/db/migrate/20180822021048_create_active_encode_encode_records.rb +13 -0
- data/lib/active_encode.rb +1 -0
- data/lib/active_encode/base.rb +6 -2
- data/lib/active_encode/callbacks.rb +18 -35
- data/lib/active_encode/core.rb +64 -20
- data/lib/active_encode/engine.rb +7 -0
- data/lib/active_encode/engine_adapter.rb +2 -2
- data/lib/active_encode/engine_adapters/active_job_adapter.rb +7 -3
- data/lib/active_encode/engine_adapters/elastic_transcoder_adapter.rb +15 -15
- data/lib/active_encode/engine_adapters/inline_adapter.rb +6 -1
- data/lib/active_encode/engine_adapters/matterhorn_adapter.rb +18 -18
- data/lib/active_encode/engine_adapters/shingoncoder_adapter.rb +13 -9
- data/lib/active_encode/engine_adapters/test_adapter.rb +19 -12
- data/lib/active_encode/engine_adapters/zencoder_adapter.rb +10 -10
- data/lib/active_encode/global_id.rb +16 -0
- data/lib/active_encode/input.rb +9 -0
- data/lib/active_encode/output.rb +9 -0
- data/lib/active_encode/persistence.rb +45 -0
- data/lib/active_encode/polling.rb +24 -0
- data/lib/active_encode/status.rb +2 -6
- data/lib/active_encode/technical_metadata.rb +16 -1
- data/lib/active_encode/version.rb +1 -1
- data/spec/fixtures/elastic_transcoder/job_canceled.json +1 -1
- data/spec/fixtures/elastic_transcoder/job_completed.json +1 -1
- data/spec/fixtures/elastic_transcoder/job_created.json +1 -1
- data/spec/fixtures/elastic_transcoder/job_failed.json +1 -1
- data/spec/fixtures/elastic_transcoder/job_progressing.json +1 -1
- data/spec/integration/elastic_transcoder_adapter_spec.rb +87 -167
- data/spec/integration/matterhorn_adapter_spec.rb +34 -79
- data/spec/integration/shingoncoder_adapter_spec.rb +1 -1
- data/spec/integration/zencoder_adapter_spec.rb +1 -1
- data/spec/rails_helper.rb +22 -0
- data/spec/shared_specs/engine_adapter_specs.rb +124 -0
- data/spec/test_app_templates/lib/generators/test_app_generator.rb +15 -0
- data/spec/units/callbacks_spec.rb +16 -17
- data/spec/units/core_spec.rb +121 -2
- data/spec/units/engine_adapter_spec.rb +0 -12
- data/spec/units/global_id_spec.rb +49 -0
- data/spec/units/input_spec.rb +12 -0
- data/spec/units/output_spec.rb +12 -0
- data/spec/units/persistence_spec.rb +57 -0
- data/spec/units/polling_job_spec.rb +86 -0
- data/spec/units/polling_spec.rb +22 -0
- data/spec/units/status_spec.rb +21 -2
- metadata +89 -20
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3346c9f9ae44a17bf60b9f71dbf6a3bd8b951883
|
4
|
+
data.tar.gz: 8f228d1184a1f61629bbed814176d131bc98de40
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 47fde2ffc94ad11ef0b8ab3af29672f3c8707128d887d2dd48249f0e13346b66b5450434ab1d79f1b840bf2ac695c05f5cd6f1ef11ce697d16bbd63193a5ad8e
|
7
|
+
data.tar.gz: 74073edc357c19e84fb51c80044684dceae704dcb8bef6061a49b62b269c78aa60fb47563db691d00fe798e76bfd7af88467290a4c135438beff51efbc2989b7
|
data/.gitignore
CHANGED
data/.travis.yml
CHANGED
data/Gemfile
CHANGED
@@ -10,3 +10,38 @@ gem 'rubocop-rspec', require: false
|
|
10
10
|
gem 'rubyhorn', git: "https://github.com/avalonmediasystem/rubyhorn.git"
|
11
11
|
gem 'shingoncoder'
|
12
12
|
gem 'zencoder'
|
13
|
+
|
14
|
+
# BEGIN ENGINE_CART BLOCK
|
15
|
+
# engine_cart: 2.0.1
|
16
|
+
# engine_cart stanza: 0.10.0
|
17
|
+
# the below comes from engine_cart, a gem used to test this Rails engine gem in the context of a Rails app.
|
18
|
+
file = File.expand_path('Gemfile', ENV['ENGINE_CART_DESTINATION'] || ENV['RAILS_ROOT'] || File.expand_path('.internal_test_app', File.dirname(__FILE__)))
|
19
|
+
if File.exist?(file)
|
20
|
+
begin
|
21
|
+
eval_gemfile file
|
22
|
+
rescue Bundler::GemfileError => e
|
23
|
+
Bundler.ui.warn '[EngineCart] Skipping Rails application dependencies:'
|
24
|
+
Bundler.ui.warn e.message
|
25
|
+
end
|
26
|
+
else
|
27
|
+
Bundler.ui.warn "[EngineCart] Unable to find test application dependencies in #{file}, using placeholder dependencies"
|
28
|
+
|
29
|
+
if ENV['RAILS_VERSION']
|
30
|
+
if ENV['RAILS_VERSION'] == 'edge'
|
31
|
+
gem 'rails', github: 'rails/rails'
|
32
|
+
ENV['ENGINE_CART_RAILS_OPTIONS'] = '--edge --skip-turbolinks'
|
33
|
+
else
|
34
|
+
gem 'rails', ENV['RAILS_VERSION']
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
case ENV['RAILS_VERSION']
|
39
|
+
when /^4.2/
|
40
|
+
gem 'responders', '~> 2.0'
|
41
|
+
gem 'sass-rails', '>= 5.0'
|
42
|
+
gem 'coffee-rails', '~> 4.1.0'
|
43
|
+
when /^4.[01]/
|
44
|
+
gem 'sass-rails', '< 5.0'
|
45
|
+
end
|
46
|
+
end
|
47
|
+
# END ENGINE_CART BLOCK
|
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# ActiveEncode
|
2
2
|
|
3
|
-
This gem serves as the basis for the interface between a Ruby (Rails) application and a provider of transcoding services such as [Opencast Matterhorn](http://opencast.org), [Zencoder](http://zencoder.com),
|
3
|
+
This gem serves as the basis for the interface between a Ruby (Rails) application and a provider of transcoding services such as [Opencast Matterhorn](http://opencast.org), [Zencoder](http://zencoder.com), and [Amazon Elastic Transcoder](http://aws.amazon.com/elastictranscoder/).
|
4
4
|
|
5
5
|
## Installation
|
6
6
|
|
@@ -20,7 +20,7 @@ Or install it yourself as:
|
|
20
20
|
|
21
21
|
## Usage
|
22
22
|
|
23
|
-
Set the engine adapter (default:
|
23
|
+
Set the engine adapter (default: test), configure it (if neccessary), then submit encoding jobs!
|
24
24
|
|
25
25
|
```ruby
|
26
26
|
ActiveEncode::Base.engine_adapter = :matterhorn
|
@@ -52,6 +52,8 @@ encode.cancel!
|
|
52
52
|
encode.cancelled? # true
|
53
53
|
```
|
54
54
|
|
55
|
+
> `#purge!` and `#remove_output!` and the following documentation have been deprecated and will be removed in ActiveEncode 0.3.
|
56
|
+
|
55
57
|
If the encoding job should be deleted, call purge:
|
56
58
|
```ruby
|
57
59
|
encode.purge!
|
@@ -67,7 +69,7 @@ An encoding job is meant to be the record of the work of the encoding engine and
|
|
67
69
|
|
68
70
|
### Custom jobs
|
69
71
|
|
70
|
-
Subclass ActiveEncode::Base to add custom callbacks or default options. Available callbacks are before, after, and around the create
|
72
|
+
Subclass ActiveEncode::Base to add custom callbacks or default options. Available callbacks are before, after, and around the create and cancel actions.
|
71
73
|
|
72
74
|
```ruby
|
73
75
|
class CustomEncode < ActiveEncode::Base
|
@@ -83,17 +85,16 @@ end
|
|
83
85
|
|
84
86
|
### Engine Adapters
|
85
87
|
|
86
|
-
Engine adapters are shims between ActiveEncode and the back end encoding service. Each service has its own API and idiosyncracies so consult the table below to see what features are supported by each adapter. Add an additional engines by creating an engine adapter class that implements :create, :find,
|
87
|
-
|
88
|
-
| Adapter/Feature | Create | Find |
|
89
|
-
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
95
|
-
|
96
|
-
| Test | X | X | | X | | X | | |
|
88
|
+
Engine adapters are shims between ActiveEncode and the back end encoding service. Each service has its own API and idiosyncracies so consult the table below to see what features are supported by each adapter. Add an additional engines by creating an engine adapter class that implements :create, :find, and :cancel.
|
89
|
+
|
90
|
+
| Adapter/Feature | Create | Find | Cancel | Preset | Multiple Outputs |
|
91
|
+
|--------------------------|--------|------|--------|--------|------------------|
|
92
|
+
| Test | X | X | X | | |
|
93
|
+
| AWS Elastic Transcoder | X | X | X | | |
|
94
|
+
| Zencoder | X | X | X | | |
|
95
|
+
| Matterhorn | X | X | X | X | X |
|
96
|
+
|
97
|
+
> The Inline and Shingoncoder adapters are deprecated and will be removed in ActiveEncode 0.3.
|
97
98
|
|
98
99
|
## Contributing
|
99
100
|
|
data/Rakefile
CHANGED
@@ -1,11 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'rake/clean'
|
2
4
|
require 'bundler'
|
3
5
|
require 'rubocop/rake_task'
|
6
|
+
require 'engine_cart/rake_task'
|
4
7
|
|
5
8
|
Bundler::GemHelper.install_tasks
|
6
9
|
|
7
10
|
desc "CI build"
|
8
|
-
task ci: ["active_encode:
|
11
|
+
task ci: ["active_encode:ci"]
|
9
12
|
desc "Rspec"
|
10
13
|
task spec: ["active_encode:ci"]
|
11
14
|
|
@@ -23,16 +26,8 @@ namespace 'active_encode' do
|
|
23
26
|
end
|
24
27
|
|
25
28
|
desc "CI build"
|
26
|
-
task ci: [:rubocop, "active_encode:environment", "
|
29
|
+
task ci: [:rubocop, "active_encode:environment", "engine_cart:generate", "active_encode:spec"]
|
27
30
|
|
28
31
|
require 'rspec/core/rake_task'
|
29
32
|
RSpec::Core::RakeTask.new(:spec)
|
30
|
-
|
31
|
-
namespace 'adapters' do
|
32
|
-
desc "Clean any local services needed by the adapters"
|
33
|
-
task 'clean' => []
|
34
|
-
|
35
|
-
desc "Start any local services needed by the adapters"
|
36
|
-
task 'start' => []
|
37
|
-
end
|
38
33
|
end
|
data/active_encode.gemspec
CHANGED
@@ -19,11 +19,14 @@ Gem::Specification.new do |spec|
|
|
19
19
|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
20
20
|
spec.require_paths = ["lib"]
|
21
21
|
|
22
|
-
spec.add_dependency "
|
22
|
+
spec.add_dependency "rails"
|
23
23
|
|
24
|
-
spec.add_development_dependency "bundler"
|
24
|
+
spec.add_development_dependency "bundler"
|
25
25
|
spec.add_development_dependency "coveralls"
|
26
|
-
spec.add_development_dependency "
|
27
|
-
spec.add_development_dependency "
|
28
|
-
spec.add_development_dependency "
|
26
|
+
spec.add_development_dependency "database_cleaner"
|
27
|
+
spec.add_development_dependency "engine_cart"
|
28
|
+
spec.add_development_dependency "rake"
|
29
|
+
spec.add_development_dependency "rspec"
|
30
|
+
spec.add_development_dependency "rspec-its"
|
31
|
+
spec.add_development_dependency "rspec-rails"
|
29
32
|
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module ActiveEncode
|
2
|
+
class PollingJob < ActiveJob::Base
|
3
|
+
|
4
|
+
def perform(encode)
|
5
|
+
encode.run_callbacks(:status_update) { encode }
|
6
|
+
case encode.state
|
7
|
+
when :error
|
8
|
+
encode.run_callbacks(:error) { encode }
|
9
|
+
when :cancelled
|
10
|
+
encode.run_callbacks(:cancelled) { encode }
|
11
|
+
when :complete
|
12
|
+
encode.run_callbacks(:complete) { encode }
|
13
|
+
when :running
|
14
|
+
ActiveEncode::PollingJob.set(wait: ActiveEncode::Polling::POLLING_WAIT_TIME).perform_later(encode)
|
15
|
+
else # other states are illegal and ignored
|
16
|
+
raise StandardError, "Illegal state #{encode.state} in encode #{encode.id}!"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class CreateActiveEncodeEncodeRecords < ActiveRecord::Migration[5.2]
|
2
|
+
def change
|
3
|
+
create_table :active_encode_encode_records do |t|
|
4
|
+
t.string :global_id
|
5
|
+
t.string :state
|
6
|
+
t.string :adapter
|
7
|
+
t.string :title
|
8
|
+
t.text :raw_object
|
9
|
+
|
10
|
+
t.timestamps
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
data/lib/active_encode.rb
CHANGED
data/lib/active_encode/base.rb
CHANGED
@@ -2,16 +2,20 @@ require 'active_encode/core'
|
|
2
2
|
require 'active_encode/engine_adapter'
|
3
3
|
require 'active_encode/status'
|
4
4
|
require 'active_encode/technical_metadata'
|
5
|
+
require 'active_encode/input'
|
6
|
+
require 'active_encode/output'
|
5
7
|
require 'active_encode/callbacks'
|
6
|
-
|
8
|
+
require 'active_encode/global_id'
|
9
|
+
require 'active_encode/persistence'
|
10
|
+
require 'active_encode/polling'
|
7
11
|
|
8
12
|
module ActiveEncode #:nodoc:
|
9
13
|
class Base
|
10
14
|
include Core
|
11
15
|
include Status
|
12
|
-
include TechnicalMetadata
|
13
16
|
include EngineAdapter
|
14
17
|
include Callbacks
|
18
|
+
include GlobalID
|
15
19
|
end
|
16
20
|
|
17
21
|
class NotFound < RuntimeError; end
|
@@ -1,4 +1,4 @@
|
|
1
|
-
require '
|
1
|
+
require 'active_model/callbacks'
|
2
2
|
|
3
3
|
module ActiveEncode
|
4
4
|
# = Active Encode Callbacks
|
@@ -6,6 +6,8 @@ module ActiveEncode
|
|
6
6
|
# Active Encode provides hooks during the life cycle of an encode. Callbacks allow you
|
7
7
|
# to trigger logic during the life cycle of an encode. Available callbacks are:
|
8
8
|
#
|
9
|
+
# * <tt>after_find</tt>
|
10
|
+
# * <tt>after_reload</tt>
|
9
11
|
# * <tt>before_create</tt>
|
10
12
|
# * <tt>around_create</tt>
|
11
13
|
# * <tt>after_create</tt>
|
@@ -18,50 +20,31 @@ module ActiveEncode
|
|
18
20
|
#
|
19
21
|
module Callbacks
|
20
22
|
extend ActiveSupport::Concern
|
21
|
-
include ActiveSupport::Callbacks
|
22
23
|
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
# These methods will be included into any Active Encode object, adding
|
30
|
-
# callbacks for +create+, +cancel+, and +purge+ methods.
|
31
|
-
module ClassMethods
|
32
|
-
def before_create(*filters, &blk)
|
33
|
-
set_callback(:create, :before, *filters, &blk)
|
34
|
-
end
|
24
|
+
CALLBACKS = [
|
25
|
+
:after_find, :after_reload, :before_create, :around_create,
|
26
|
+
:after_create, :before_cancel, :around_cancel, :after_cancel,
|
27
|
+
:before_purge, :around_purge, :after_purge
|
28
|
+
].freeze
|
35
29
|
|
36
|
-
|
37
|
-
|
38
|
-
end
|
39
|
-
|
40
|
-
def around_create(*filters, &blk)
|
41
|
-
set_callback(:create, :around, *filters, &blk)
|
42
|
-
end
|
43
|
-
|
44
|
-
def before_cancel(*filters, &blk)
|
45
|
-
set_callback(:cancel, :before, *filters, &blk)
|
46
|
-
end
|
47
|
-
|
48
|
-
def after_cancel(*filters, &blk)
|
49
|
-
set_callback(:cancel, :after, *filters, &blk)
|
50
|
-
end
|
30
|
+
included do
|
31
|
+
extend ActiveModel::Callbacks
|
51
32
|
|
52
|
-
|
53
|
-
|
54
|
-
end
|
33
|
+
define_model_callbacks :find, :reload, only: :after
|
34
|
+
define_model_callbacks :create, :cancel, :purge
|
55
35
|
|
56
|
-
def before_purge(*filters, &blk)
|
36
|
+
def self.before_purge(*filters, &blk)
|
37
|
+
ActiveSupport::Deprecation.warn("before_purge will be removed without replacement in ActiveEncode 0.3")
|
57
38
|
set_callback(:purge, :before, *filters, &blk)
|
58
39
|
end
|
59
40
|
|
60
|
-
def after_purge(*filters, &blk)
|
41
|
+
def self.after_purge(*filters, &blk)
|
42
|
+
ActiveSupport::Deprecation.warn("after_purge will be removed without replacement in ActiveEncode 0.3")
|
61
43
|
set_callback(:purge, :after, *filters, &blk)
|
62
44
|
end
|
63
45
|
|
64
|
-
def around_purge(*filters, &blk)
|
46
|
+
def self.around_purge(*filters, &blk)
|
47
|
+
ActiveSupport::Deprecation.warn("around_purge will be removed without replacement in ActiveEncode 0.3")
|
65
48
|
set_callback(:purge, :around, *filters, &blk)
|
66
49
|
end
|
67
50
|
end
|
data/lib/active_encode/core.rb
CHANGED
@@ -10,71 +10,115 @@ module ActiveEncode
|
|
10
10
|
attr_accessor :id
|
11
11
|
|
12
12
|
# Encode input
|
13
|
+
# @return ActiveEncode::Input
|
13
14
|
attr_accessor :input
|
14
15
|
|
15
16
|
# Encode output(s)
|
17
|
+
# @return Array[ActiveEncode::Output]
|
16
18
|
attr_accessor :output
|
17
19
|
|
18
20
|
# Encode options
|
19
21
|
attr_accessor :options
|
22
|
+
|
23
|
+
attr_accessor :current_operations
|
24
|
+
attr_accessor :percent_complete
|
25
|
+
|
26
|
+
# @deprecated
|
27
|
+
attr_accessor :tech_metadata
|
20
28
|
end
|
21
29
|
|
22
30
|
module ClassMethods
|
23
|
-
def default_options(
|
31
|
+
def default_options(_input_url)
|
24
32
|
{}
|
25
33
|
end
|
26
34
|
|
27
|
-
def create(
|
28
|
-
object = new(
|
35
|
+
def create(input_url, options = {})
|
36
|
+
object = new(input_url, options)
|
29
37
|
object.create!
|
30
38
|
end
|
31
39
|
|
32
40
|
def find(id)
|
33
41
|
raise ArgumentError, 'id cannot be nil' unless id
|
34
|
-
|
42
|
+
encode = new(nil)
|
43
|
+
encode.run_callbacks :find do
|
44
|
+
encode.send(:merge!, engine_adapter.find(id))
|
45
|
+
end
|
35
46
|
end
|
36
47
|
|
37
|
-
|
48
|
+
def list(*args)
|
49
|
+
ActiveSupport::Deprecation.warn("#list will be removed without replacement in ActiveEncode 0.3")
|
50
|
+
engine_adapter.list(args)
|
51
|
+
end
|
38
52
|
end
|
39
53
|
|
40
|
-
def initialize(
|
41
|
-
@input = input
|
42
|
-
@options = options || self.class.default_options(
|
54
|
+
def initialize(input_url, options = nil)
|
55
|
+
@input = Input.new.tap{ |input| input.url = input_url }
|
56
|
+
@options = options || self.class.default_options(input_url)
|
43
57
|
end
|
44
58
|
|
45
59
|
def create!
|
60
|
+
# TODO: Raise ArgumentError if self has an id?
|
46
61
|
run_callbacks :create do
|
47
|
-
self.class.engine_adapter.create self
|
62
|
+
merge!(self.class.engine_adapter.create(self.input.url, self.options))
|
48
63
|
end
|
49
64
|
end
|
50
65
|
|
51
66
|
def cancel!
|
52
67
|
run_callbacks :cancel do
|
53
|
-
self.class.engine_adapter.cancel
|
68
|
+
merge!(self.class.engine_adapter.cancel(self.id))
|
54
69
|
end
|
55
70
|
end
|
56
71
|
|
57
72
|
def purge!
|
73
|
+
ActiveSupport::Deprecation.warn("#purge! will be removed without replacement in ActiveEncode 0.3")
|
58
74
|
run_callbacks :purge do
|
59
75
|
self.class.engine_adapter.purge self
|
60
76
|
end
|
61
77
|
end
|
62
78
|
|
63
79
|
def remove_output!(output_id)
|
80
|
+
ActiveSupport::Deprecation.warn("#remove_output will be removed without replacement in ActiveEncode 0.3")
|
64
81
|
self.class.engine_adapter.remove_output self, output_id
|
65
82
|
end
|
66
83
|
|
67
84
|
def reload
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
@tech_metadata = fresh_encode.tech_metadata
|
76
|
-
|
77
|
-
self
|
85
|
+
run_callbacks :reload do
|
86
|
+
merge!(self.class.engine_adapter.find(id))
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def created?
|
91
|
+
!id.nil?
|
78
92
|
end
|
93
|
+
|
94
|
+
# @deprecated
|
95
|
+
def tech_metadata
|
96
|
+
metadata = {}
|
97
|
+
[:width, :height, :frame_rate, :duration, :file_size,
|
98
|
+
:audio_codec, :video_codec, :audio_bitrate, :video_bitrate, :checksum].each do |key|
|
99
|
+
metadata[key] = input.send(key)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
protected
|
104
|
+
|
105
|
+
def merge!(encode)
|
106
|
+
@id = encode.id
|
107
|
+
@input = encode.input
|
108
|
+
@output = encode.output
|
109
|
+
@options = encode.options
|
110
|
+
@state = encode.state
|
111
|
+
@errors = encode.errors
|
112
|
+
@created_at = encode.created_at
|
113
|
+
@updated_at = encode.updated_at
|
114
|
+
@current_operations = encode.current_operations
|
115
|
+
@percent_complete = encode.percent_complete
|
116
|
+
|
117
|
+
# deprecated
|
118
|
+
@tech_metadata = encode.tech_metadata
|
119
|
+
@finished_at = encode.finished_at
|
120
|
+
|
121
|
+
self
|
122
|
+
end
|
79
123
|
end
|
80
124
|
end
|