berkeley_library-av-core 0.4.0

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 (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