thingfish 0.5.0.pre20160707192835

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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.simplecov +7 -0
  3. data/History.rdoc +5 -0
  4. data/LICENSE +29 -0
  5. data/Manifest.txt +39 -0
  6. data/Procfile +4 -0
  7. data/README.rdoc +92 -0
  8. data/Rakefile +92 -0
  9. data/bin/tfprocessord +6 -0
  10. data/bin/thingfish +10 -0
  11. data/etc/thingfish.conf.example +26 -0
  12. data/lib/strelka/app/metadata.rb +38 -0
  13. data/lib/strelka/httprequest/metadata.rb +70 -0
  14. data/lib/thingfish.rb +43 -0
  15. data/lib/thingfish/behaviors.rb +263 -0
  16. data/lib/thingfish/datastore.rb +55 -0
  17. data/lib/thingfish/datastore/memory.rb +93 -0
  18. data/lib/thingfish/handler.rb +728 -0
  19. data/lib/thingfish/metastore.rb +55 -0
  20. data/lib/thingfish/metastore/memory.rb +201 -0
  21. data/lib/thingfish/mixins.rb +57 -0
  22. data/lib/thingfish/processor.rb +79 -0
  23. data/lib/thingfish/processor/mp3.rb +167 -0
  24. data/lib/thingfish/processordaemon.rb +16 -0
  25. data/lib/thingfish/spechelpers.rb +165 -0
  26. data/spec/data/APIC-1-image.mp3 +0 -0
  27. data/spec/data/APIC-2-images.mp3 +0 -0
  28. data/spec/data/PIC-1-image.mp3 +0 -0
  29. data/spec/data/PIC-2-images.mp3 +0 -0
  30. data/spec/helpers.rb +67 -0
  31. data/spec/spec.opts +4 -0
  32. data/spec/thingfish/datastore/memory_spec.rb +19 -0
  33. data/spec/thingfish/datastore_spec.rb +64 -0
  34. data/spec/thingfish/handler_spec.rb +838 -0
  35. data/spec/thingfish/metastore/memory_spec.rb +17 -0
  36. data/spec/thingfish/metastore_spec.rb +96 -0
  37. data/spec/thingfish/mixins_spec.rb +63 -0
  38. data/spec/thingfish/processor/mp3_spec.rb +50 -0
  39. data/spec/thingfish/processor_spec.rb +65 -0
  40. data/spec/thingfish_spec.rb +23 -0
  41. metadata +244 -0
@@ -0,0 +1,55 @@
1
+ # -*- ruby -*-
2
+ #encoding: utf-8
3
+
4
+ require 'pluggability'
5
+ require 'strelka'
6
+ require 'strelka/mixins'
7
+
8
+ require 'thingfish' unless defined?( Thingfish )
9
+ require 'thingfish/mixins'
10
+
11
+ # The base class for storage mechanisms used by Thingfish to store its data
12
+ # blobs.
13
+ class Thingfish::Metastore
14
+ extend Pluggability,
15
+ Strelka::AbstractClass
16
+ include Thingfish::Normalization
17
+
18
+
19
+ # Pluggability API -- set the prefix for implementations of Metastore
20
+ plugin_prefixes 'thingfish/metastore'
21
+
22
+ # AbstractClass API -- register some virtual methods that must be implemented
23
+ # in subclasses
24
+ pure_virtual :oids,
25
+ :each_oid,
26
+ :save,
27
+ :search,
28
+ :fetch,
29
+ :fetch_value,
30
+ :fetch_related_oids,
31
+ :merge,
32
+ :include?,
33
+ :remove,
34
+ :remove_except,
35
+ :size
36
+
37
+ ### Return a representation of the object as a String suitable for debugging.
38
+ def inspect
39
+ return "#<%p:%#016x %d objects>" % [
40
+ self.class,
41
+ self.object_id * 2,
42
+ self.size
43
+ ]
44
+ end
45
+
46
+
47
+ ### Provide transactional consistency to the provided block. Concrete metastores should
48
+ ### override this if they can implement it. By default it's a no-op.
49
+ def transaction
50
+ yield
51
+ end
52
+
53
+
54
+ end # class Thingfish::Metastore
55
+
@@ -0,0 +1,201 @@
1
+ # -*- ruby -*-
2
+ #encoding: utf-8
3
+
4
+ require 'thingfish' unless defined?( Thingfish )
5
+ require 'thingfish/metastore' unless defined?( Thingfish::Metastore )
6
+
7
+
8
+
9
+ # An in-memory metastore for testing and tryout purposes.
10
+ class Thingfish::Metastore::Memory < Thingfish::Metastore
11
+ extend Loggability
12
+ include Thingfish::Normalization
13
+
14
+ # Loggability API -- log to the :thingfish logger
15
+ log_to :thingfish
16
+
17
+
18
+ ### Create a new MemoryMetastore, using the given +storage+ object to store
19
+ ### data in. The +storage+ should quack like a Hash.
20
+ def initialize( storage={} )
21
+ @storage = storage
22
+ end
23
+
24
+
25
+ ##
26
+ # The raw Hash of metadata
27
+ attr_reader :storage
28
+
29
+
30
+ ### Return an Array of all stored OIDs.
31
+ def oids
32
+ return @storage.keys
33
+ end
34
+
35
+
36
+ ### Iterate over the OID of each entry in the store, yielding to the block if one is given
37
+ ### or returning an Enumerator if one is not.
38
+ def each_oid( &block )
39
+ return @storage.each_key( &block )
40
+ end
41
+
42
+
43
+ ### Save the +metadata+ Hash for the specified +oid+.
44
+ def save( oid, metadata )
45
+ oid = normalize_oid( oid )
46
+ @storage[ oid ] = metadata.dup
47
+ end
48
+
49
+
50
+ ### Fetch the data corresponding to the given +oid+ as a Hash-ish object.
51
+ def fetch( oid, *keys )
52
+ oid = normalize_oid( oid )
53
+ metadata = @storage[ oid ] or return nil
54
+
55
+ if keys.empty?
56
+ self.log.debug "Fetching metadata for OID %s" % [ oid ]
57
+ return metadata.dup
58
+ else
59
+ self.log.debug "Fetching metadata for %p for OID %s" % [ keys, oid ]
60
+ keys = normalize_keys( keys )
61
+ values = metadata.values_at( *keys )
62
+ return Hash[ [keys, values].transpose ]
63
+ end
64
+ end
65
+
66
+
67
+ ### Fetch the value of the metadata associated with the given +key+ for the
68
+ ### specified +oid+.
69
+ def fetch_value( oid, key )
70
+ oid = normalize_oid( oid )
71
+ key = normalize_key( key )
72
+ data = @storage[ oid ] or return nil
73
+
74
+ return data[ key ]
75
+ end
76
+
77
+
78
+ ### Fetch OIDs related to the given +oid+.
79
+ def fetch_related_oids( oid )
80
+ oid = normalize_oid( oid )
81
+ self.log.debug "Fetching OIDs of resources related to %s" % [ oid ]
82
+ return self.search( :criteria => {:relation => oid}, :include_related => true )
83
+ end
84
+
85
+
86
+ ### Search the metastore for UUIDs which match the specified +criteria+ and
87
+ ### return them as an iterator.
88
+ def search( options={} )
89
+ ds = @storage.each_key
90
+ self.log.debug "Starting search with %p" % [ ds ]
91
+
92
+ ds = self.omit_related_resources( ds, options )
93
+ ds = self.apply_search_criteria( ds, options )
94
+ ds = self.apply_search_order( ds, options )
95
+ ds = self.apply_search_direction( ds, options )
96
+ ds = self.apply_search_limit( ds, options )
97
+
98
+ return ds.to_a
99
+ end
100
+
101
+
102
+ ### Omit related resources from the search dataset +ds+ unless the given
103
+ ### +options+ specify otherwise.
104
+ def omit_related_resources( ds, options )
105
+ unless options[:include_related]
106
+ ds = ds.reject {|uuid| @storage[uuid]['relationship'] }
107
+ end
108
+ return ds
109
+ end
110
+
111
+
112
+ ### Apply the search :criteria from the specified +options+ to the collection
113
+ ### in +ds+ and return the modified dataset.
114
+ def apply_search_criteria( ds, options )
115
+ if (( criteria = options[:criteria] ))
116
+ criteria.each do |field, value|
117
+ self.log.debug " applying criteria: %p => %p" % [ field.to_s, value ]
118
+ ds = ds.select {|uuid| @storage[uuid][field.to_s] == value }
119
+ end
120
+ end
121
+
122
+ return ds
123
+ end
124
+
125
+
126
+ ### Apply the search :order from the specified +options+ to the collection in
127
+ ### +ds+ and return the modified dataset.
128
+ def apply_search_order( ds, options )
129
+ if (( fields = options[:order] ))
130
+ ds = ds.to_a.sort_by do |uuid|
131
+ @storage[ uuid ].values_at( *fields.compact ).map {|val| val || ''}
132
+ end
133
+ end
134
+
135
+ return ds
136
+ end
137
+
138
+
139
+ ### Apply the search :direction from the specified +options+ to the collection
140
+ ### in +ds+ and return the modified dataset.
141
+ def apply_search_direction( ds, options )
142
+ ds.reverse! if options[:direction] && options[:direction] == 'desc'
143
+ return ds
144
+ end
145
+
146
+
147
+ ### Apply the search :limit from the specified +options+ to the collection in
148
+ ### +ds+ and return the modified dataset.
149
+ def apply_search_limit( ds, options )
150
+ if (( limit = options[:limit] ))
151
+ self.log.debug " limiting to %s results" % [ limit ]
152
+ offset = options[:offset] || 0
153
+ ds = ds.to_a.slice( offset, limit )
154
+ end
155
+
156
+ return ds
157
+ end
158
+
159
+
160
+ ### Update the metadata for the given +oid+ with the specified +values+ hash.
161
+ def merge( oid, values )
162
+ oid = normalize_oid( oid )
163
+ values = normalize_keys( values )
164
+ @storage[ oid ].merge!( values )
165
+ end
166
+
167
+
168
+ ### Remove all metadata associated with +oid+ from the Metastore.
169
+ def remove( oid, *keys )
170
+ oid = normalize_oid( oid )
171
+ if keys.empty?
172
+ @storage.delete( oid )
173
+ else
174
+ keys = normalize_keys( keys )
175
+ @storage[ oid ].delete_if {|key, _| keys.include?(key) }
176
+ end
177
+ end
178
+
179
+
180
+ ### Remove all metadata associated with +oid+ except for the specified +keys+.
181
+ def remove_except( oid, *keys )
182
+ oid = normalize_oid( oid )
183
+ keys = normalize_keys( keys )
184
+ @storage[ oid ].keep_if {|key,_| keys.include?(key) }
185
+ end
186
+
187
+
188
+ ### Returns +true+ if the metastore has metadata associated with the specified +oid+.
189
+ def include?( oid )
190
+ oid = normalize_oid( oid )
191
+ return @storage.include?( oid )
192
+ end
193
+
194
+
195
+ ### Returns the number of objects the store contains.
196
+ def size
197
+ return @storage.size
198
+ end
199
+
200
+ end # class Thingfish::Metastore::Memory
201
+
@@ -0,0 +1,57 @@
1
+ # -*- ruby -*-
2
+ # vim: set nosta noet ts=4 sw=4:
3
+ # encoding: utf-8
4
+
5
+ require 'securerandom'
6
+
7
+ require 'thingfish' unless defined?( Thingfish )
8
+
9
+ module Thingfish
10
+
11
+ # A collection of functions for dealing with object IDs.
12
+ module Normalization
13
+
14
+ ###############
15
+ module_function
16
+ ###############
17
+
18
+ ### Generate a new object ID.
19
+ def make_object_id
20
+ return normalize_oid( SecureRandom.uuid )
21
+ end
22
+
23
+
24
+ ### Normalize the given +oid+.
25
+ def normalize_oid( oid )
26
+ return oid.to_s.downcase
27
+ end
28
+
29
+
30
+ ### Return a copy of the given +collection+ after being normalized.
31
+ def normalize_keys( collection )
32
+ if collection.respond_to?( :keys )
33
+ return collection.each_with_object({}) do |(key,val),new_hash|
34
+ n_key = normalize_key( key )
35
+ new_hash[ n_key ] = val
36
+ end
37
+
38
+ elsif collection.respond_to?( :map )
39
+ return collection.map {|key| normalize_key(key) }
40
+ end
41
+
42
+ return nil
43
+ end
44
+
45
+
46
+ ### Return a normalized copy of +key+.
47
+ def normalize_key( key )
48
+ return key.to_s.downcase.gsub( /[^\w:]+/, '_' )
49
+ end
50
+
51
+ end # module Normalization
52
+
53
+
54
+ end # module Thingfish
55
+
56
+ # vim: set nosta noet ts=4 sw=4:
57
+
@@ -0,0 +1,79 @@
1
+ # -*- ruby -*-
2
+ #encoding: utf-8
3
+
4
+ require 'pluggability'
5
+ require 'strelka/httprequest/acceptparams'
6
+
7
+ require 'thingfish' unless defined?( Thingfish )
8
+
9
+
10
+ # Thingfish asset processor base class.
11
+ class Thingfish::Processor
12
+ extend Pluggability
13
+
14
+
15
+ plugin_prefixes 'thingfish/processor'
16
+
17
+
18
+ ### Get/set the list of media types this processor can handle.
19
+ def self::handled_types( *mediatypes )
20
+ if mediatypes.empty?
21
+ @handled_types ||= []
22
+ else
23
+ @handled_types = mediatypes.collect do |type|
24
+ Strelka::HTTPRequest::MediaType.parse(type)
25
+ end
26
+ end
27
+
28
+ return @handled_types
29
+ end
30
+
31
+
32
+ ### Filter hook for request, pass to processor if it is able to
33
+ ### handle the +request+ content type.
34
+ def process_request( request )
35
+ return unless self.handled_path?( request )
36
+ if self.handled_type?( request.content_type )
37
+ on_request( request )
38
+ end
39
+ end
40
+
41
+
42
+ ### Process the data and/or metadata in the +request+.
43
+ def on_request( request )
44
+ # No-op by default
45
+ end
46
+
47
+
48
+ ### Filter hook for response, pass to processor if it is able to
49
+ ### handle the +response+ content type.
50
+ def process_response( response )
51
+ return unless self.handled_path?( response.request )
52
+ if self.handled_type?( response.content_type )
53
+ on_response( response )
54
+ end
55
+ end
56
+
57
+
58
+ ### Process the data and/or metadata in the +response+.
59
+ def on_response( response )
60
+ # No-op by default
61
+ end
62
+
63
+
64
+ ### Returns +true+ if the given media +type+ is one the processor handles.
65
+ def handled_type?( type )
66
+ return true if self.class.handled_types.empty?
67
+ self.class.handled_types.find {|handled_type| type =~ handled_type }
68
+ end
69
+ alias_method :is_handled_type?, :handled_type?
70
+
71
+
72
+ ### Returns +true+ if the given +request+'s path is one that should
73
+ ### be processed.
74
+ def handled_path?( request )
75
+ return ! request.path.match( %r|^/?[\w\-]+/metadata| )
76
+ end
77
+
78
+ end # class Thingfish::Processor
79
+
@@ -0,0 +1,167 @@
1
+ # -*- ruby -*-
2
+ #encoding: utf-8
3
+
4
+ require 'mp3info'
5
+
6
+ require 'thingfish' unless defined?( Thingfish )
7
+ require 'thingfish/processor' unless defined?( Thingfish::Processor )
8
+
9
+
10
+ # Attach ID3 data to an mp3, along with any embedded album art as a related resource.
11
+ class Thingfish::Processor::MP3 < Thingfish::Processor
12
+ extend Loggability
13
+
14
+ # Loggability API -- log to the :thingfish logger
15
+ log_to :thingfish
16
+
17
+ # The list of handled types
18
+ handled_types 'audio/mpeg', 'audio/mpg', 'audio/mp3'
19
+
20
+
21
+ # Null character
22
+ NULL = "\x0"
23
+
24
+ # Attached picture "PIC"
25
+ # Frame size $xx xx xx
26
+ # ---- mp3info is 'helpfully' cropping out frame size.
27
+ # Text encoding $xx
28
+ # Image format $xx xx xx
29
+ # Picture type $xx
30
+ # Description <textstring> $00 (00)
31
+ # Picture data <binary data>
32
+ PIC_FORMAT = 'h a3 h Z* a*'
33
+
34
+ # Attached picture "APIC"
35
+ # Text encoding $xx
36
+ # MIME type <text string> $00
37
+ # Picture type $xx
38
+ # Description <text string according to encoding> $00 (00)
39
+ # Picture data <binary data>
40
+ APIC_FORMAT = 'h Z* h Z* a*'
41
+
42
+
43
+ ### Synchronous processor API -- extract metadata from uploaded MP3s
44
+ def on_request( request )
45
+ mp3info = Mp3Info.new( request.body )
46
+
47
+ mp3_metadata = self.extract_id3_metadata( mp3info )
48
+ request.add_metadata( mp3_metadata )
49
+
50
+ self.extract_images( mp3info ) do |imageio, metadata|
51
+ metadata[:title] = "Album art for %s - %s" % mp3_metadata.values_at( 'mp3:artist', 'mp3:title' )
52
+ request.add_related_resource( imageio, metadata )
53
+ end
54
+ end
55
+
56
+
57
+ ### Normalize metadata from the MP3Info object and return it as a hash.
58
+ def extract_id3_metadata( mp3info )
59
+ self.log.debug "Extracting MP3 metadata"
60
+
61
+ mp3_metadata = {
62
+ 'mp3:frequency' => mp3info.samplerate,
63
+ 'mp3:bitrate' => mp3info.bitrate,
64
+ 'mp3:vbr' => mp3info.vbr,
65
+ 'mp3:title' => mp3info.tag.title,
66
+ 'mp3:artist' => mp3info.tag.artist,
67
+ 'mp3:album' => mp3info.tag.album,
68
+ 'mp3:year' => mp3info.tag.year,
69
+ 'mp3:genre' => mp3info.tag.genre,
70
+ 'mp3:tracknum' => mp3info.tag.tracknum,
71
+ 'mp3:comments' => mp3info.tag.comments,
72
+ }
73
+
74
+ # ID3V2 2.2.0 has three-letter tags, so map those if the artist info isn't set
75
+ if mp3info.hastag2?
76
+ if mp3_metadata['mp3:artist'].nil?
77
+ self.log.debug " extracting old-style ID3v2 info" % [mp3info.tag2.version]
78
+
79
+ mp3_metadata.merge!({
80
+ 'mp3:title' => mp3info.tag2.TT2,
81
+ 'mp3:artist' => mp3info.tag2.TP1,
82
+ 'mp3:album' => mp3info.tag2.TAL,
83
+ 'mp3:year' => mp3info.tag2.TYE,
84
+ 'mp3:tracknum' => mp3info.tag2.TRK,
85
+ 'mp3:comments' => mp3info.tag2.COM,
86
+ 'mp3:genre' => mp3info.tag2.TCO,
87
+ })
88
+ end
89
+ end
90
+
91
+ self.log.debug " raw metadata: %p" % [ mp3_metadata ]
92
+ return sanitize_values( mp3_metadata )
93
+ end
94
+
95
+
96
+ ### Extract image data from ID3 information, supports both APIC (2.3) and the older style
97
+ ### PIC (2.2). Return value is a hash with IO keys and mimetype values.
98
+ ### {
99
+ ### io => { format => 'image/jpeg' }
100
+ ### io2 => { format => 'image/jpeg' }
101
+ ### }
102
+ def extract_images( mp3info )
103
+ self.log.debug "Extracting embedded images"
104
+ raise LocalJumpError, "no block given" unless block_given?
105
+
106
+ unless mp3info.hastag2?
107
+ self.log.debug "...no id3v2 tag, so no embedded images possible."
108
+ return
109
+ end
110
+
111
+ self.log.debug "...id3v2 tag present..."
112
+
113
+ if mp3info.tag2.APIC
114
+ self.log.debug "...extracting APIC (id3v2.3+) image data."
115
+
116
+ images = [ mp3info.tag2.APIC ].flatten
117
+ images.each do |img|
118
+ blob, mime = img.unpack( APIC_FORMAT ).values_at( 4, 1 )
119
+ yield( StringIO.new(blob),
120
+ :format => mime,
121
+ :extent => blob.length,
122
+ :relationship => 'album-art' )
123
+ end
124
+
125
+ elsif mp3info.tag2.PIC
126
+ self.log.debug "...extracting PIC (id3v2.2) image data."
127
+
128
+ images = [ mp3info.tag2.PIC ].flatten
129
+ images.each do |img|
130
+ blob, type = img.unpack( PIC_FORMAT ).values_at( 4, 1 )
131
+ mime = Mongrel2::Config.mimetypes[ ".#{type.downcase}" ] or next
132
+ yield( StringIO.new(blob),
133
+ :format => mime,
134
+ :extent => blob.length,
135
+ :relationship => 'album-art' )
136
+ end
137
+
138
+ else
139
+ self.log.debug "...no known image tag types in tags: %p" % [ mp3info.tag2.keys.sort ]
140
+ end
141
+ end
142
+
143
+
144
+
145
+ #######
146
+ private
147
+ #######
148
+
149
+ ### Strip NULLs from the values of the given +metadata_hash+ and return it.
150
+ def sanitize_values( metadata_hash )
151
+ metadata_hash.each do |k,v|
152
+ case v
153
+ when String
154
+ metadata_hash[k] = v.chomp(NULL).strip
155
+ when Array
156
+ metadata_hash[k] = v.collect {|vv| vv.chomp(NULL).strip }
157
+ when Numeric, TrueClass, FalseClass
158
+ # No-op
159
+ end
160
+ end
161
+
162
+ return metadata_hash.delete_if {|_,v| v.nil? }
163
+ end
164
+
165
+
166
+ end # class Thingfish::Processor::MP3
167
+