berkeley_library-av-core 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (104) hide show
  1. checksums.yaml +7 -0
  2. data/.dockerignore +391 -0
  3. data/.github/workflows/build.yml +30 -0
  4. data/.gitignore +388 -0
  5. data/.idea/av_core.iml +146 -0
  6. data/.idea/codeStyles/Project.xml +12 -0
  7. data/.idea/codeStyles/codeStyleConfig.xml +5 -0
  8. data/.idea/go.imports.xml +6 -0
  9. data/.idea/inspectionProfiles/Project_Default.xml +37 -0
  10. data/.idea/misc.xml +6 -0
  11. data/.idea/modules.xml +8 -0
  12. data/.idea/vcs.xml +6 -0
  13. data/.rubocop.yml +241 -0
  14. data/.ruby-version +1 -0
  15. data/.simplecov +8 -0
  16. data/CHANGES.md +38 -0
  17. data/Gemfile +3 -0
  18. data/Jenkinsfile +16 -0
  19. data/LICENSE.md +21 -0
  20. data/README.md +20 -0
  21. data/Rakefile +20 -0
  22. data/av_core.gemspec +49 -0
  23. data/lib/berkeley_library/av/config.rb +238 -0
  24. data/lib/berkeley_library/av/constants.rb +30 -0
  25. data/lib/berkeley_library/av/core/module_info.rb +18 -0
  26. data/lib/berkeley_library/av/core.rb +7 -0
  27. data/lib/berkeley_library/av/marc/util.rb +114 -0
  28. data/lib/berkeley_library/av/marc.rb +52 -0
  29. data/lib/berkeley_library/av/metadata/README.md +5 -0
  30. data/lib/berkeley_library/av/metadata/field.rb +110 -0
  31. data/lib/berkeley_library/av/metadata/fields.rb +130 -0
  32. data/lib/berkeley_library/av/metadata/link.rb +28 -0
  33. data/lib/berkeley_library/av/metadata/readers/alma.rb +54 -0
  34. data/lib/berkeley_library/av/metadata/readers/base.rb +53 -0
  35. data/lib/berkeley_library/av/metadata/readers/tind.rb +52 -0
  36. data/lib/berkeley_library/av/metadata/readers.rb +2 -0
  37. data/lib/berkeley_library/av/metadata/source.rb +93 -0
  38. data/lib/berkeley_library/av/metadata/tind_html_metadata_da.json +2076 -0
  39. data/lib/berkeley_library/av/metadata/value.rb +121 -0
  40. data/lib/berkeley_library/av/metadata.rb +103 -0
  41. data/lib/berkeley_library/av/record.rb +86 -0
  42. data/lib/berkeley_library/av/record_id.rb +121 -0
  43. data/lib/berkeley_library/av/record_not_found.rb +7 -0
  44. data/lib/berkeley_library/av/restrictions.rb +36 -0
  45. data/lib/berkeley_library/av/track.rb +132 -0
  46. data/lib/berkeley_library/av/types/duration.rb +67 -0
  47. data/lib/berkeley_library/av/types/file_type.rb +84 -0
  48. data/lib/berkeley_library/av/util.rb +65 -0
  49. data/rakelib/bundle.rake +8 -0
  50. data/rakelib/coverage.rake +11 -0
  51. data/rakelib/gem.rake +54 -0
  52. data/rakelib/rubocop.rake +18 -0
  53. data/rakelib/spec.rake +12 -0
  54. data/spec/.rubocop.yml +116 -0
  55. data/spec/data/10.23.19.JessieLaCavalier.02.mrc +3 -0
  56. data/spec/data/alma/991005939359706532-sru.xml +123 -0
  57. data/spec/data/alma/991034756419706532-sru.xml +162 -0
  58. data/spec/data/alma/991047179369706532-sru.xml +210 -0
  59. data/spec/data/alma/991054360089706532-sru.xml +186 -0
  60. data/spec/data/alma/b11082434-sru.xml +165 -0
  61. data/spec/data/alma/b18538031-sru.xml +123 -0
  62. data/spec/data/alma/b20786580-sru.xml +123 -0
  63. data/spec/data/alma/b22139647-sru.xml +171 -0
  64. data/spec/data/alma/b22139658-sru.xml +282 -0
  65. data/spec/data/alma/b23161018-sru.xml +182 -0
  66. data/spec/data/alma/b23305522-sru.xml +144 -0
  67. data/spec/data/alma/b24071548-sru.xml +136 -0
  68. data/spec/data/alma/b24659129-sru.xml +210 -0
  69. data/spec/data/alma/b25207857-sru.xml +217 -0
  70. data/spec/data/alma/b25716973-sru.xml +186 -0
  71. data/spec/data/alma/b25742488-sru.xml +246 -0
  72. data/spec/data/record-(cityarts)00002.xml +78 -0
  73. data/spec/data/record-(cityarts)00773.xml +94 -0
  74. data/spec/data/record-(clir)00020.xml +153 -0
  75. data/spec/data/record-(miscmat)00615.xml +45 -0
  76. data/spec/data/record-(pacradio)00107.xml +85 -0
  77. data/spec/data/record-(pacradio)01469.xml +82 -0
  78. data/spec/data/record-empty-result.xml +4 -0
  79. data/spec/data/record-multiple-998s-disordered.xml +178 -0
  80. data/spec/data/record-multiple-998s.xml +178 -0
  81. data/spec/data/record-physcolloquia-bk00169017b.xml +78 -0
  82. data/spec/data/record-ragged-998-subfields.xml +122 -0
  83. data/spec/data/record-ragged-998s-multiple-fields.xml +160 -0
  84. data/spec/data/record-redirect-to-login.html +288 -0
  85. data/spec/data/record_id/bibs_with_check_digits.txt +151 -0
  86. data/spec/data/search-1993.xml +158 -0
  87. data/spec/data/search-b23305516.xml +81 -0
  88. data/spec/lib/berkeley_library/av/av_spec.rb +12 -0
  89. data/spec/lib/berkeley_library/av/config_spec.rb +250 -0
  90. data/spec/lib/berkeley_library/av/marc/util_spec.rb +150 -0
  91. data/spec/lib/berkeley_library/av/marc_spec.rb +62 -0
  92. data/spec/lib/berkeley_library/av/metadata/field_spec.rb +81 -0
  93. data/spec/lib/berkeley_library/av/metadata/fields_spec.rb +180 -0
  94. data/spec/lib/berkeley_library/av/metadata/metadata_spec.rb +274 -0
  95. data/spec/lib/berkeley_library/av/metadata/source_spec.rb +261 -0
  96. data/spec/lib/berkeley_library/av/metadata/value_spec.rb +29 -0
  97. data/spec/lib/berkeley_library/av/record_id_spec.rb +72 -0
  98. data/spec/lib/berkeley_library/av/record_spec.rb +284 -0
  99. data/spec/lib/berkeley_library/av/track_spec.rb +335 -0
  100. data/spec/lib/berkeley_library/av/types/duration_spec.rb +91 -0
  101. data/spec/lib/berkeley_library/av/types/file_type_spec.rb +98 -0
  102. data/spec/lib/berkeley_library/av/util_spec.rb +30 -0
  103. data/spec/spec_helper.rb +63 -0
  104. metadata +499 -0
@@ -0,0 +1,121 @@
1
+ require 'berkeley_library/util/uris'
2
+ require 'berkeley_library/av/util'
3
+ require 'berkeley_library/av/metadata/link'
4
+
5
+ module BerkeleyLibrary
6
+ module AV
7
+ class Metadata
8
+ class Value
9
+ include AV::Util
10
+ include Comparable
11
+
12
+ # ------------------------------------------------------------
13
+ # Constants
14
+
15
+ URL_CODE = :u
16
+ BODY_CODES = %i[y z].freeze
17
+
18
+ # ------------------------------------------------------------
19
+ # Accessors
20
+
21
+ # TODO: find uses of :entries & replace them with standardized string value method here
22
+ attr_reader :tag, :label, :entries, :order
23
+
24
+ # ------------------------------------------------------------
25
+ # Initializers
26
+
27
+ def initialize(tag:, label:, entries:, order:)
28
+ raise ArgumentError, 'Entries cannot be empty' if entries.empty?
29
+
30
+ @tag = tag
31
+ @label = label
32
+ @order = order
33
+ @entries = entries
34
+ end
35
+
36
+ # ------------------------------------------------------------
37
+ # Public methods
38
+
39
+ def includes_link?(link_body)
40
+ entries.any? do |entry|
41
+ entry.is_a?(Link) && entry.body == link_body
42
+ end
43
+ end
44
+
45
+ def as_string
46
+ entries.join(' ').gsub(/[[:space:]]+/, ' ')
47
+ end
48
+
49
+ # ------------------------------
50
+ # Object overrides
51
+
52
+ def to_s
53
+ StringIO.new.tap do |out|
54
+ out << "#{label} (#{tag}): "
55
+ out << entries.join(' ')
56
+ end.string
57
+ end
58
+
59
+ # @param other [Value] the Value to compare
60
+ def <=>(other)
61
+ compare_by_attributes(self, other, :order, :tag, :entries, :label)
62
+ end
63
+
64
+ # ------------------------------------------------------------
65
+ # Class methods
66
+
67
+ class << self
68
+ include AV::Util
69
+ include BerkeleyLibrary::Util::URIs
70
+
71
+ def value_for(field, subfield_groups)
72
+ return if subfield_groups.empty?
73
+ return if (all_entries = entries_from_groups(subfield_groups, field.subfields_separator)).empty?
74
+
75
+ Value.new(tag: field.tag, label: field.label, entries: all_entries, order: field.order)
76
+ end
77
+
78
+ def link_value(field, link)
79
+ Value.new(tag: field.tag, label: field.label, order: field.order, entries: [link])
80
+ end
81
+
82
+ private
83
+
84
+ def entries_from_groups(subfield_groups, separator)
85
+ subfield_groups.each_with_object([]) do |sg, entries|
86
+ entries.concat(entries_from_group(sg, separator))
87
+ end
88
+ end
89
+
90
+ def entries_from_group(subfield_group, separator)
91
+ [].tap do |entries|
92
+ link = extract_link(subfield_group)
93
+ entries << link if link
94
+
95
+ subfield_values = subfield_group.values.map { |sf| tidy_value(sf.value) }
96
+ entries << subfield_values.join(separator) unless subfield_values.empty?
97
+ end
98
+ end
99
+
100
+ def extract_link(subfield_group)
101
+ return unless (url_sf = subfield_group.delete(URL_CODE))
102
+
103
+ url = url_sf.value
104
+ body = link_body_from(subfield_group) || url
105
+ AV::Metadata::Link.new(url:, body:)
106
+ end
107
+
108
+ def link_body_from(subfield_group)
109
+ body_sf = BODY_CODES.lazy.filter_map { |code| subfield_group.delete(code) }.first
110
+ return unless body_sf
111
+
112
+ body_value = tidy_value(body_sf.value)
113
+ body_value unless body_value.empty?
114
+ end
115
+
116
+ end
117
+
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,103 @@
1
+ require 'berkeley_library/av/constants'
2
+ require 'berkeley_library/av/metadata/fields'
3
+ require 'berkeley_library/av/metadata/source'
4
+ require 'berkeley_library/av/restrictions'
5
+
6
+ module BerkeleyLibrary
7
+ module AV
8
+ class Metadata
9
+ include AV::Constants
10
+
11
+ attr_reader :record_id, :source
12
+
13
+ # TODO: can we stop passing in record ID / stop lazy-loading MARC?
14
+ def initialize(record_id:, source:, marc_record: nil)
15
+ @record_id = record_id
16
+ @source = source
17
+ @marc_record = marc_record
18
+ end
19
+
20
+ def bib_number
21
+ return @bib_number if instance_variable_defined?(:@bib_number)
22
+
23
+ @bib_number = source.find_bib_number(self)
24
+ end
25
+
26
+ def tind_id
27
+ id_001 if source == Source::TIND
28
+ end
29
+
30
+ def alma_id
31
+ id_001 if source == Source::ALMA
32
+ end
33
+
34
+ def marc_record
35
+ @marc_record ||= source.record_for(record_id)
36
+ end
37
+
38
+ def values_by_field
39
+ @values_by_field ||= Fields.default_values_from(marc_record).tap { |values| ensure_catalog_link(values) }
40
+ end
41
+
42
+ def each_value(&)
43
+ values_by_field.each_value(&)
44
+ end
45
+
46
+ def title
47
+ @title ||= (title_value = values_by_field[Fields::TITLE]) ? title_value.entries.first : UNKNOWN_TITLE
48
+ end
49
+
50
+ def description
51
+ @description ||= (desc_value = values_by_field[Fields::DESCRIPTION]) ? desc_value.as_string : ''
52
+ end
53
+
54
+ def calnet_or_ip?
55
+ restrictions.calnet_or_ip?
56
+ end
57
+
58
+ def calnet_only?
59
+ restrictions.calnet_only?
60
+ end
61
+
62
+ def display_uri
63
+ @display_uri ||= source.display_uri_for(self)
64
+ end
65
+
66
+ class << self
67
+ def for_record(record_id:)
68
+ source = Source.for_record_id(record_id)
69
+ raise AV::RecordNotFound, "Unable to determine metadata source for record ID: #{record_id.inspect}" unless source
70
+
71
+ Metadata.new(record_id:, source:)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def restrictions
78
+ @restrictions ||= Restrictions.new(marc_record)
79
+ end
80
+
81
+ def id_001
82
+ return @id_001 if instance_variable_defined?(:@id_001)
83
+ return (@id_001 = nil) unless (cf_001 = marc_record['001'])
84
+
85
+ @id_001 = RecordId.ensure_record_id(cf_001.value)
86
+ end
87
+
88
+ def ensure_catalog_link(values_by_field)
89
+ catalog_value = values_by_field[Fields::CATALOG_LINK]
90
+ return if catalog_value && catalog_value.includes_link?(source.catalog_link_text)
91
+
92
+ catalog_link = Link.new(url: display_uri.to_s, body: source.catalog_link_text)
93
+
94
+ if catalog_value
95
+ catalog_value.entries << catalog_link
96
+ else
97
+ values_by_field[Fields::CATALOG_LINK] = Value.link_value(Fields::CATALOG_LINK, catalog_link)
98
+ end
99
+ end
100
+
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,86 @@
1
+ require 'berkeley_library/util'
2
+
3
+ require 'berkeley_library/av/track'
4
+ require 'berkeley_library/av/metadata'
5
+ require 'berkeley_library/av/metadata/source'
6
+
7
+ module BerkeleyLibrary
8
+ module AV
9
+ class Record
10
+ include BerkeleyLibrary::Util
11
+
12
+ attr_reader :collection, :tracks, :metadata
13
+
14
+ # TODO: stop passing in track list & just get it from metadata & collection
15
+ def initialize(collection:, tracks:, metadata:)
16
+ @collection = collection
17
+ @tracks = tracks.sort
18
+ @metadata = metadata
19
+ end
20
+
21
+ def title
22
+ metadata.title
23
+ end
24
+
25
+ def bib_number
26
+ metadata.bib_number
27
+ end
28
+
29
+ def tind_id
30
+ metadata.tind_id
31
+ end
32
+
33
+ def record_id
34
+ metadata.record_id
35
+ end
36
+
37
+ def calnet_or_ip?
38
+ metadata.calnet_or_ip?
39
+ end
40
+
41
+ def calnet_only?
42
+ metadata.calnet_only?
43
+ end
44
+
45
+ def type_label
46
+ @type_label ||= begin
47
+ file_types = Set.new(tracks.map(&:file_type)).to_a.sort
48
+ file_types = AV::Types::FileType.to_a if file_types.empty?
49
+
50
+ file_types.map(&:label).join(' / ')
51
+ end
52
+ end
53
+
54
+ def player_uri
55
+ @player_uri ||= URIs.append(AV::Config.avplayer_base_uri, collection, record_id)
56
+ end
57
+
58
+ def display_uri
59
+ metadata.display_uri
60
+ end
61
+
62
+ def description
63
+ metadata.description
64
+ end
65
+
66
+ class << self
67
+ # Loads the metadata for the specified record and creates a record object from it.
68
+ #
69
+ # Note that for TIND records the record ID is *not* the TIND internal ID
70
+ # (MARC field 001) but rather the ID assigned by the UC Berkeley Library
71
+ # (MARC field 035).
72
+ #
73
+ # @param collection [String] The collection name (Wowza application id).
74
+ # @param record_id [String] The record ID.
75
+ def from_metadata(collection:, record_id:)
76
+ metadata = Metadata.for_record(record_id:)
77
+ Record.new(
78
+ collection:,
79
+ metadata:,
80
+ tracks: Track.tracks_from(metadata.marc_record, collection:)
81
+ )
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,121 @@
1
+ require 'typesafe_enum'
2
+ require 'berkeley_library/av/constants'
3
+
4
+ # TODO: use berkeley_library/alma to replace as much of this as possible
5
+ module BerkeleyLibrary
6
+ module AV
7
+ class RecordId
8
+ include AV::Util
9
+ include Comparable
10
+
11
+ # ------------------------------------------------------------
12
+ # Fields
13
+
14
+ attr_reader :id, :type
15
+
16
+ # ------------------------------------------------------------
17
+ # Initializer
18
+
19
+ def initialize(id)
20
+ @id = id.to_s
21
+ @type = Type.for_id(@id)
22
+ end
23
+
24
+ # ------------------------------------------------------------
25
+ # Class methods
26
+
27
+ class << self
28
+ include AV::Constants
29
+
30
+ def ensure_record_id(record_id)
31
+ return record_id if record_id.is_a?(RecordId)
32
+
33
+ RecordId.new(record_id)
34
+ end
35
+
36
+ def ensure_check_digit(bib_number)
37
+ digit_str, check_str = split_bib(bib_number)
38
+
39
+ digits = digit_str.chars.map(&:to_i)
40
+ check_digit = calculate_check_digit(digits)
41
+
42
+ return "b#{digit_str}#{check_digit}" if check_str.nil? || check_str == 'a'
43
+ return bib_number if check_str == check_digit
44
+
45
+ raise ArgumentError, "#{bib_number} check digit invalid: expected #{check_digit}, got #{check_str}"
46
+ end
47
+
48
+ def strip_check_digit(bib_number)
49
+ digit_str, = split_bib(bib_number)
50
+ "b#{digit_str}"
51
+ end
52
+
53
+ private
54
+
55
+ def split_bib(bib_number)
56
+ raise ArgumentError, "Not a Millennium bib number: #{bib_number.inspect}" unless (md = MILLENNIUM_RECORD_RE.match(bib_number.to_s))
57
+
58
+ %i[digits check].map { |part| md[part] }
59
+ end
60
+
61
+ def calculate_check_digit(digits)
62
+ raise ArgumentError, "Not an 8-digit array : #{digits.inspect}" unless digits.is_a?(Array) && digits.size == 8
63
+
64
+ # From: http://liwong.blogspot.com/2018/04/recipe-computing-millennium-checkdigit.html
65
+ mod = digits.reverse.each_with_index.inject(0) { |sum, (v, i)| sum + (v * (i + 2)) } % 11
66
+ mod == 10 ? 'x' : mod.to_s
67
+ end
68
+ end
69
+
70
+ # ------------------------------------------------------------
71
+ # Comparable
72
+
73
+ def <=>(other)
74
+ compare_by_attributes(self, other, :id, :type)
75
+ end
76
+
77
+ # ------------------------------------------------------------
78
+ # Object overrides
79
+
80
+ def hash
81
+ [id, type].hash
82
+ end
83
+
84
+ def to_s
85
+ id
86
+ end
87
+
88
+ def inspect
89
+ "#{self.class}(#{id} [#{type}])@#{object_id}"
90
+ end
91
+
92
+ # ------------------------------------------------------------
93
+ # Helper classes
94
+
95
+ class Type < TypesafeEnum::Base
96
+ new :ALMA
97
+ new :MILLENNIUM
98
+ new :OCLC
99
+ new :TIND
100
+
101
+ def to_s
102
+ key.to_s
103
+ end
104
+
105
+ class << self
106
+ include AV::Constants
107
+
108
+ def for_id(id)
109
+ return unless id
110
+ return id.type if id.is_a?(RecordId)
111
+ return Type::ALMA if ALMA_RECORD_RE =~ id
112
+ return Type::MILLENNIUM if MILLENNIUM_RECORD_RE =~ id
113
+ return Type::OCLC if OCLC_RECORD_RE =~ id
114
+
115
+ Type::TIND
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,7 @@
1
+ module BerkeleyLibrary
2
+ module AV
3
+ class RecordNotFound < RuntimeError
4
+
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,36 @@
1
+ module BerkeleyLibrary
2
+ module AV
3
+ class Restrictions
4
+
5
+ # TODO: remove 85642$y and 95640$z once we confirm all records have 998$r
6
+ SUBFIELD_VALUE_SPECS = %w[998$r/#-# 856$y/#-#{^1=\4}{^2=\2} 956$z/#-#{^1=\4}{^2=\0}].freeze
7
+
8
+ RE_CALNET_ONLY = /CalNet/i
9
+ RE_CALNET_OR_IP = /UCB access|UCB only/i
10
+
11
+ attr_reader :marc_record
12
+
13
+ def initialize(marc_record)
14
+ @marc_record = marc_record
15
+ end
16
+
17
+ def calnet_only?
18
+ @calnet ||= any_field_value_matches?(RE_CALNET_ONLY)
19
+ end
20
+
21
+ def calnet_or_ip?
22
+ @ucb_ip ||= any_field_value_matches?(RE_CALNET_OR_IP)
23
+ end
24
+
25
+ private
26
+
27
+ def any_field_value_matches?(re)
28
+ field_values.any? { |v| re =~ v }
29
+ end
30
+
31
+ def field_values
32
+ @field_values ||= SUBFIELD_VALUE_SPECS.flat_map { |spec| MARC::Spec.find(spec, marc_record) }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,132 @@
1
+ require 'berkeley_library/av/constants'
2
+ require 'berkeley_library/av/types/duration'
3
+ require 'berkeley_library/av/types/file_type'
4
+ require 'berkeley_library/av/marc/util'
5
+ require 'marc'
6
+
7
+ module BerkeleyLibrary
8
+ module AV
9
+ class Track
10
+ include Comparable
11
+ include AV::Constants
12
+ include AV::Util
13
+
14
+ attr_reader :sort_order, :title, :path, :duration, :file_type
15
+
16
+ def initialize(sort_order:, path:, title: nil, duration: nil)
17
+ @sort_order = sort_order
18
+ @title = title
19
+ @path = path
20
+ @duration = duration_or_nil(duration)
21
+ @file_type = AV::Types::FileType.for_path(path)
22
+ end
23
+
24
+ def <=>(other)
25
+ compare_by_attributes(self, other, :sort_order, :title, :duration, :path)
26
+ end
27
+
28
+ def to_s
29
+ ''.tap do |s|
30
+ s << "#{sort_order}: " if sort_order
31
+ s << path
32
+ s << " #{title.inspect}" if title
33
+ s << " (#{duration})" if duration
34
+ end
35
+ end
36
+
37
+ def inspect
38
+ "\#<#{self.class.name} #{self}>"
39
+ end
40
+
41
+ # @return [Array<MARC::Subfield>]
42
+ def to_marc_subfields
43
+ [].tap do |subfields|
44
+ subfields << MARC::Subfield.new(SUBFIELD_CODE_DURATION, duration.to_s) if duration
45
+ subfields << MARC::Subfield.new(SUBFIELD_CODE_TITLE, title) if title
46
+ subfields << MARC::Subfield.new(SUBFIELD_CODE_PATH, path)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def duration_or_nil(duration)
53
+ return duration if duration.is_a?(AV::Types::Duration)
54
+
55
+ AV::Types::Duration.from_string(duration)
56
+ end
57
+
58
+ class << self
59
+ include AV::Constants
60
+ include AV::Marc::Util
61
+
62
+ LABELS = {
63
+ SUBFIELD_CODE_DURATION => 'duration',
64
+ SUBFIELD_CODE_TITLE => 'title',
65
+ SUBFIELD_CODE_PATH => 'path'
66
+ }.freeze
67
+
68
+ # Note that if multiple tracks are encoded in the same 998 field, the
69
+ # subfields **must** be in the order :a, :t, :g (duration, title, path),
70
+ # as documented in {https://docs.google.com/document/d/1gRWsaSoerSvadNlYR-zbYOjgj0geLxV41bBC0rm5nHE/edit
71
+ # "How to add media to the AV System"}.
72
+ #
73
+ # @param marc_record [MARC::Record] the MARC record
74
+ # @param collection [String] the collection
75
+ def tracks_from(marc_record, collection:)
76
+ track_fields = marc_record.fields(TAG_TRACK_FIELD)
77
+ track_fields.each_with_object([]) do |df, all_tracks|
78
+ value_groups = group_values(df.subfields)
79
+ value_groups.each do |group|
80
+ all_tracks << from_value_group(group, collection:, sort_order: all_tracks.size)
81
+ end
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def group_values(subfields)
88
+ filtered = subfields.select { |sf| SUBFIELD_CODES_TRACKS.include?(sf.code.to_sym) }
89
+ group_subfields(filtered).map { |sfg| to_value_group(sfg) }
90
+ end
91
+
92
+ def group_subfields(subfields)
93
+ single_track = subfields.lazy.select { |sf| sf.code.to_sym == SUBFIELD_CODE_PATH }.one?
94
+ return [group_together(subfields)] if single_track
95
+
96
+ group_on_paths(subfields)
97
+ end
98
+
99
+ def group_together(subfields)
100
+ subfields.each_with_object({}) { |sf, grp| grp[sf.code.to_sym] = sf }
101
+ end
102
+
103
+ def group_on_paths(subfields)
104
+ current_group = {}
105
+ subfields.each_with_object([]) do |subfield, groups|
106
+ code_sym = subfield.code.to_sym
107
+
108
+ current_group[code_sym] = subfield
109
+ if code_sym == SUBFIELD_CODE_PATH
110
+ groups << current_group
111
+ current_group = {}
112
+ end
113
+ end
114
+ end
115
+
116
+ def to_value_group(subfield_group)
117
+ subfield_group.transform_values { |sf| tidy_value(sf.value) }
118
+ end
119
+
120
+ def from_value_group(group, collection:, sort_order:)
121
+ Track.new(
122
+ sort_order:,
123
+ title: group[SUBFIELD_CODE_TITLE],
124
+ path: "#{collection}/#{group[SUBFIELD_CODE_PATH]}",
125
+ duration: group[SUBFIELD_CODE_DURATION]
126
+ )
127
+ end
128
+
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,67 @@
1
+ module BerkeleyLibrary
2
+ module AV
3
+ module Types
4
+ class Duration
5
+ include Comparable
6
+
7
+ DURATION_RE = /^([0-9]{1,2})?:?([0-9]{2}):?([0-9]{2})$/
8
+
9
+ attr_reader :total_seconds
10
+
11
+ def initialize(hours: 0, minutes: 0, seconds: 0)
12
+ @total_seconds = (3600 * hours) + (60 * minutes) + seconds
13
+ end
14
+
15
+ def hours
16
+ @hours ||= (total_seconds / 3600).floor
17
+ end
18
+
19
+ def minutes
20
+ remainder = total_seconds % 3600
21
+ (remainder / 60).floor
22
+ end
23
+
24
+ def seconds
25
+ remainder = total_seconds % 60
26
+ remainder.floor
27
+ end
28
+
29
+ def to_s
30
+ format('%02d:%02d:%02d', hours, minutes, seconds)
31
+ end
32
+
33
+ def <=>(other)
34
+ return 0 if equal?(other)
35
+ return unless other
36
+ return unless other.respond_to?(:total_seconds)
37
+
38
+ total_seconds <=> other.total_seconds
39
+ end
40
+
41
+ class << self
42
+ # @return [Duration] the duration, or nil
43
+ def from_string(s)
44
+ return unless s
45
+
46
+ value = clean_value(s)
47
+ return unless (md = DURATION_RE.match(value))
48
+
49
+ Duration.new(
50
+ hours: md[1].to_i,
51
+ minutes: md[2].to_i,
52
+ seconds: md[3].to_i
53
+ )
54
+ end
55
+
56
+ private
57
+
58
+ def clean_value(value)
59
+ raise ArgumentError, "Not a string: #{value.inspect}" unless value.is_a?(String)
60
+
61
+ value.gsub(/[[:space:]]/, '')
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end