thingfish 0.5.0.pre20160707192835

Sign up to get free protection for your applications and to get access to all the features.
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
+