mlbserver 2022.3.22 → 2022.4.9

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 (4) hide show
  1. package/README.md +1 -1
  2. package/index.js +23 -6
  3. package/package.json +1 -1
  4. package/session.js +110 -72
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # mlbserver
2
2
 
3
- Current version 2022.03.22
3
+ Current version 2022.04.09
4
4
 
5
5
  Credit to https://github.com/tonycpsu/streamglob and https://github.com/mafintosh/hls-decryptor
6
6
 
package/index.js CHANGED
@@ -383,6 +383,8 @@ function getMasterPlaylist(streamURL, req, res, options = {}) {
383
383
  if ( resolution.endsWith('p60') ) {
384
384
  frame_rate = '59.94'
385
385
  resolution = resolution.slice(0, -3)
386
+ } else if ( resolution.endsWith('p') ) {
387
+ resolution = resolution.slice(0, -1)
386
388
  }
387
389
  }
388
390
 
@@ -793,6 +795,7 @@ app.get('/', async function(req, res) {
793
795
 
794
796
  let gameDate = session.liveDate()
795
797
  let todayUTCHours = session.getTodayUTCHours()
798
+ let curDate = new Date()
796
799
  if ( req.query.date ) {
797
800
  if ( req.query.date == VALID_DATES[1] ) {
798
801
  gameDate = session.yesterdayDate()
@@ -800,7 +803,6 @@ app.get('/', async function(req, res) {
800
803
  gameDate = req.query.date
801
804
  }
802
805
  } else {
803
- let curDate = new Date()
804
806
  let utcHours = curDate.getUTCHours()
805
807
  if ( (utcHours >= todayUTCHours) && (utcHours < YESTERDAY_UTC_HOURS) ) {
806
808
  gameDate = session.yesterdayDate()
@@ -1015,7 +1017,13 @@ app.get('/', async function(req, res) {
1015
1017
  if ( (currentDate >= compareStart) && (currentDate < compareEnd) ) {
1016
1018
  let querystring = '?event=biginning'
1017
1019
  let multiviewquerystring = querystring + '&resolution=' + DEFAULT_MULTIVIEW_RESOLUTION
1020
+ if ( linkType == 'embed' ) {
1021
+ if ( startFrom != 'Beginning' ) querystring += '&startFrom=' + startFrom
1022
+ }
1018
1023
  if ( resolution != VALID_RESOLUTIONS[0] ) querystring += '&resolution=' + resolution
1024
+ if ( linkType == 'stream' ) {
1025
+ if ( force_vod != VALID_FORCE_VOD[0] ) querystring += '&force_vod=' + force_vod
1026
+ }
1019
1027
  querystring += content_protect_b
1020
1028
  multiviewquerystring += content_protect_b
1021
1029
  body += '<a href="' + thislink + querystring + '">Big Inning</a>'
@@ -1227,7 +1235,11 @@ app.get('/', async function(req, res) {
1227
1235
  station += '*'
1228
1236
  }
1229
1237
  if ( (cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaState == 'MEDIA_ON') || (cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaState == 'MEDIA_ARCHIVE') || cache_data.dates[0].games[j].gameUtils.isFinal ) {
1230
- game_started = true
1238
+ let gameTime = new Date(cache_data.dates[0].games[j].gameDate)
1239
+ gameTime.setMinutes(gameTime.getMinutes()-10)
1240
+ if ( curDate >= gameTime ) {
1241
+ game_started = true
1242
+ }
1231
1243
  let mediaId = cache_data.dates[0].games[j].content.media.epg[k].items[x].mediaId
1232
1244
  if ( (mediaType == 'MLBTV') && session.cache.media && session.cache.media[mediaId] && session.cache.media[mediaId].blackout && session.cache.media[mediaId].blackoutExpiry && (new Date(session.cache.media[mediaId].blackoutExpiry) > new Date()) ) {
1233
1245
  body += teamabbr + ': <s>' + station + '</s>'
@@ -1293,7 +1305,7 @@ app.get('/', async function(req, res) {
1293
1305
  if ( body.substr(-2) == ', ' ) {
1294
1306
  body = body.slice(0, -2)
1295
1307
  }
1296
- if ( (mediaType == 'MLBTV') && (game_started) ) {
1308
+ if ( (mediaType == 'MLBTV') && (game_started) && cache_data.dates[0].games[j].content && cache_data.dates[0].games[j].content.summary && cache_data.dates[0].games[j].content.summary.hasHighlightsVideo ) {
1297
1309
  body += '<br/><a href="javascript:showhighlights(\'' + cache_data.dates[0].games[j].gamePk + '\',\'' + gameDate + '\')">Highlights</a>'
1298
1310
  }
1299
1311
  if ( body.substr(-2) == ', ' ) {
@@ -1392,7 +1404,7 @@ app.get('/', async function(req, res) {
1392
1404
 
1393
1405
  body += '<p><span class="tooltip">Include (or exclude) LIDOM<span class="tooltiptext">Dominican Winter League, aka Liga de Beisbol Dominicano. Live stream only, does not support starting from the beginning or certain innings, skip options, etc.</span></span>: <a href="/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=lidom' + content_protect_b + '">channels.m3u</a> and <a href="/guide.xml?mediaType=' + mediaType + '&includeTeams=lidom' + content_protect_b + '">guide.xml</a></p>' + "\n"
1394
1406
 
1395
- body += '<p><span class="tooltip">Include (or exclude) Big Inning<span class="tooltiptext">Big Inning is the live look-in and highlights show. Live stream only, does not support starting from the beginning.</span></span>: <a href="/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=biginning' + content_protect_b + '">channels.m3u</a> and <a href="/guide.xml?mediaType=' + mediaType + '&includeTeams=biginning' + content_protect_b + '">guide.xml</a></p>' + "\n"
1407
+ body += '<p><span class="tooltip">Include (or exclude) Big Inning<span class="tooltiptext">Big Inning is the live look-in and highlights show.</span></span>: <a href="/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=biginning' + content_protect_b + '">channels.m3u</a> and <a href="/guide.xml?mediaType=' + mediaType + '&includeTeams=biginning' + content_protect_b + '">guide.xml</a></p>' + "\n"
1396
1408
 
1397
1409
  body += '<p><span class="tooltip">Include (or exclude) Multiview<span class="tooltiptext">Requires starting and stopping the multiview stream from the web interface.</span></span>: <a href="/channels.m3u?mediaType=' + mediaType + '&resolution=' + resolution + '&includeTeams=multiview' + content_protect_b + '">channels.m3u</a> and <a href="/guide.xml?mediaType=' + mediaType + '&includeTeams=multiview' + content_protect_b + '">guide.xml</a></p>' + "\n"
1398
1410
 
@@ -1925,13 +1937,18 @@ function start_multiview_stream(streams, sync, dvr, faster, audio_url, audio_url
1925
1937
  .addOutputOption('-master_pl_name', multiview_stream_name)
1926
1938
  .addOutputOption('-y')
1927
1939
  .output(session.get_multiview_directory() + '/stream-%v.m3u8')
1928
- .on('start', function() {
1940
+ .on('start', function(commandLine) {
1929
1941
  session.log('multiview stream started')
1930
1942
  ffmpeg_status = true
1943
+ if ( argv.debug || argv.ffmpeg_logging ) {
1944
+ session.log('multiview stream command: ' + commandLine)
1945
+ }
1931
1946
  })
1932
- .on('error', function(err) {
1947
+ .on('error', function(err, stdout, stderr) {
1933
1948
  session.log('multiview stream stopped: ' + err.message)
1934
1949
  ffmpeg_status = false
1950
+ if ( stdout ) session.log(stdout)
1951
+ if ( stderr ) session.log(stderr)
1935
1952
  })
1936
1953
  .on('end', function() {
1937
1954
  session.log('multiview stream ended')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlbserver",
3
- "version": "2022.03.22",
3
+ "version": "2022.04.09",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
package/session.js CHANGED
@@ -82,9 +82,8 @@ class sessionClass {
82
82
  }
83
83
  }
84
84
 
85
- // Create storage directories if they don't already exist
85
+ // Create storage directory if it doesn't already exist
86
86
  this.createDirectory(DATA_DIRECTORY)
87
- this.createFile(COOKIE_FILE)
88
87
 
89
88
  // Set multiview path
90
89
  if ( argv.multiview_path ) {
@@ -96,6 +95,16 @@ class sessionClass {
96
95
  }
97
96
  this.createDirectory(this.multiview_path)
98
97
 
98
+ // Create cookie storage file if it doesn't already exist
99
+ this.createFile(COOKIE_FILE)
100
+ // Verify its contents are valid
101
+ let cookieStr = fs.readFileSync(COOKIE_FILE)
102
+ if ( (cookieStr != '') && !this.isValidJson(cookieStr) ) {
103
+ this.log('invalid cookie storage file contents, resetting')
104
+ fs.unlinkSync(COOKIE_FILE)
105
+ this.createFile(COOKIE_FILE)
106
+ }
107
+
99
108
  // Set up http requests with the cookie jar
100
109
  this.request = require('request-promise')
101
110
  this.jar = this.request.jar(new FileCookieStore(COOKIE_FILE))
@@ -384,29 +393,18 @@ class sessionClass {
384
393
  clear_multiview_files() {
385
394
  try {
386
395
  if ( this.multiview_path ) {
387
- fs.rmdir(this.multiview_path, { recursive: true }, (err) => {
388
- if (err) throw err;
396
+ fs.readdir(this.multiview_path, (err, files) => {
397
+ if (err) throw err
389
398
 
390
- this.createDirectory(this.multiview_path)
399
+ for (const file of files) {
400
+ fs.unlink(path.join(this.multiview_path, file), err => {
401
+ if (err) throw err
402
+ })
403
+ }
391
404
  })
392
405
  }
393
406
  } catch(e){
394
- this.debuglog('recursive clear multiview files error: ' + e.message)
395
- try {
396
- if ( this.multiview_path ) {
397
- fs.readdir(this.multiview_path, (err, files) => {
398
- if (err) throw err
399
-
400
- for (const file of files) {
401
- fs.unlink(path.join(this.multiview_path, file), err => {
402
- if (err) throw err
403
- })
404
- }
405
- })
406
- }
407
- } catch(e){
408
- this.debuglog('clear multiview files error : ' + e.message)
409
- }
407
+ this.debuglog('clear multiview files error : ' + e.message)
410
408
  }
411
409
  }
412
410
 
@@ -565,17 +563,25 @@ class sessionClass {
565
563
  gzip: true
566
564
  }
567
565
  var response = await this.httpGet(reqObj)
568
- // disabled because it's very big!
569
- //this.debuglog('getApiKeys response : ' + response)
570
- var parsed = response.match('"x-api-key","value":"([^"]+)"')
571
- if ( parsed[1] ) {
572
- this.data.xApiKey = parsed[1]
573
- this.save_session_data()
574
- }
575
- parsed = response.match('"clientApiKey":"([^"]+)"')
576
- if ( parsed[1] ) {
577
- this.data.clientApiKey = parsed[1]
578
- this.save_session_data()
566
+ if ( response ) {
567
+ // disabled because it's very big!
568
+ //this.debuglog('getApiKeys response : ' + response)
569
+ var parsed = response.match('"x-api-key","value":"([^"]+)"')
570
+ if ( parsed[1] ) {
571
+ this.data.xApiKey = parsed[1]
572
+ this.save_session_data()
573
+ } else {
574
+ this.log('getApiKeys xApiKey parse failure')
575
+ }
576
+ parsed = response.match('"clientApiKey":"([^"]+)"')
577
+ if ( parsed[1] ) {
578
+ this.data.clientApiKey = parsed[1]
579
+ this.save_session_data()
580
+ } else {
581
+ this.log('getApiKeys clientApiKey parse failure')
582
+ }
583
+ } else {
584
+ this.log('getApiKeys response failure')
579
585
  }
580
586
  }
581
587
 
@@ -594,13 +600,19 @@ class sessionClass {
594
600
  gzip: true
595
601
  }
596
602
  var response = await this.httpGet(reqObj)
597
- // disabled because it's very big!
598
- //this.debuglog('getOktaClientId response : ' + response)
599
- var parsed = response.match('production:{clientId:"([^"]+)",')
600
- if ( parsed[1] ) {
601
- this.data.oktaClientId = parsed[1]
602
- this.save_session_data()
603
- return this.data.oktaClientId
603
+ if ( response ) {
604
+ // disabled because it's very big!
605
+ //this.debuglog('getOktaClientId response : ' + response)
606
+ var parsed = response.match('production:{clientId:"([^"]+)",')
607
+ if ( parsed[1] ) {
608
+ this.data.oktaClientId = parsed[1]
609
+ this.save_session_data()
610
+ return this.data.oktaClientId
611
+ } else {
612
+ this.log('getOktaClientId parse failure')
613
+ }
614
+ } else {
615
+ this.log('getOktaClientId response failure')
604
616
  }
605
617
  } else {
606
618
  return this.data.oktaClientId
@@ -672,6 +684,8 @@ class sessionClass {
672
684
  this.cacheStreamURL(mediaId, obj.stream.complete)
673
685
  return obj.stream.complete
674
686
  }
687
+ } else {
688
+ this.log('getStreamURL response failure')
675
689
  }
676
690
  }
677
691
  }
@@ -710,6 +724,8 @@ class sessionClass {
710
724
  this.data.bamAccessTokenExpiry = new Date(new Date().getTime() + obj.expires_in * 1000)
711
725
  this.save_session_data()
712
726
  return this.data.bamAccessToken
727
+ } else {
728
+ this.log('getBamAccessToken response failure')
713
729
  }
714
730
  } else {
715
731
  return this.data.bamAccessToken
@@ -735,9 +751,13 @@ class sessionClass {
735
751
  gzip: true
736
752
  }
737
753
  var response = await this.httpGet(reqObj)
738
- this.debuglog('getEntitlementToken response : ' + response)
739
- this.debuglog('getEntitlementToken : ' + response)
740
- return response
754
+ if ( response ) {
755
+ this.debuglog('getEntitlementToken response : ' + response)
756
+ this.debuglog('getEntitlementToken : ' + response)
757
+ return response
758
+ } else {
759
+ this.log('getEntitlementToken response failure')
760
+ }
741
761
  }
742
762
 
743
763
  async getDeviceId() {
@@ -764,6 +784,8 @@ class sessionClass {
764
784
  let obj = JSON.parse(response)
765
785
  this.debuglog('getDeviceId : ' + obj.device.id)
766
786
  return obj.device.id
787
+ } else {
788
+ this.log('getDeviceId response failure')
767
789
  }
768
790
  }
769
791
 
@@ -791,6 +813,8 @@ class sessionClass {
791
813
  let obj = JSON.parse(response)
792
814
  this.debuglog('getDeviceAccessToken : ' + obj.access_token)
793
815
  return obj.access_token
816
+ } else {
817
+ this.log('getDeviceAccessToken response failure')
794
818
  }
795
819
  }
796
820
 
@@ -815,6 +839,8 @@ class sessionClass {
815
839
  this.debuglog('getDevicesAssertion response : ' + response)
816
840
  this.debuglog('getDevicesAssertion : ' + response.assertion)
817
841
  return response.assertion
842
+ } else {
843
+ this.log('getDevicesAssertion response failure')
818
844
  }
819
845
  }
820
846
 
@@ -854,30 +880,37 @@ class sessionClass {
854
880
  }
855
881
  }
856
882
  var response = await this.httpGet(reqObj)
857
- var str = response.toString()
858
- this.debuglog('retrieveOktaAccessToken response : ' + str)
859
- if ( str.match ) {
860
- var errorParsed = str.match("data.error = 'login_required'")
861
- if ( errorParsed && errorParsed[1] ) {
862
- // Need to log in again
863
- this.log('Logging in...')
864
- this.data.authnSessionToken = null
865
- this.save_session_data()
866
- return false
867
- } else {
868
- var parsed_token = str.match("data.access_token = '([^']+)'")
869
- var parsed_expiry = str.match("data.expires_in = '([^']+)'")
870
- if ( parsed_token && parsed_token[1] && parsed_expiry && parsed_expiry[1] ) {
871
- let oktaAccessToken = parsed_token[1].split('\\x2D').join('-')
872
- let oktaAccessTokenExpiry = parsed_expiry[1]
873
- this.debuglog('retrieveOktaAccessToken : ' + oktaAccessToken)
874
- this.debuglog('retrieveOktaAccessToken expires in : ' + oktaAccessTokenExpiry)
875
- this.data.oktaAccessToken = oktaAccessToken
876
- this.data.oktaAccessTokenExpiry = new Date(new Date().getTime() + oktaAccessTokenExpiry * 1000)
883
+ if ( response ) {
884
+ var str = response.toString()
885
+ this.debuglog('retrieveOktaAccessToken response : ' + str)
886
+ if ( str.match ) {
887
+ var errorParsed = str.match("data.error = 'login_required'")
888
+ if ( errorParsed ) {
889
+ // Need to log in again
890
+ this.log('Logging in...')
891
+ delete this.data.authnSessionToken
877
892
  this.save_session_data()
878
- return this.data.oktaAccessToken
893
+ } else {
894
+ var parsed_token = str.match("data.access_token = '([^']+)'")
895
+ var parsed_expiry = str.match("data.expires_in = '([^']+)'")
896
+ if ( parsed_token && parsed_token[1] && parsed_expiry && parsed_expiry[1] ) {
897
+ let oktaAccessToken = parsed_token[1].split('\\x2D').join('-')
898
+ let oktaAccessTokenExpiry = parsed_expiry[1]
899
+ this.debuglog('retrieveOktaAccessToken : ' + oktaAccessToken)
900
+ this.debuglog('retrieveOktaAccessToken expires in : ' + oktaAccessTokenExpiry)
901
+ this.data.oktaAccessToken = oktaAccessToken
902
+ this.data.oktaAccessTokenExpiry = new Date(new Date().getTime() + oktaAccessTokenExpiry * 1000)
903
+ this.save_session_data()
904
+ return this.data.oktaAccessToken
905
+ } else {
906
+ this.log('retrieveOktaAccessToken parse failure')
907
+ }
879
908
  }
909
+ } else {
910
+ this.log('retrieveOktaAccessToken string response failure')
880
911
  }
912
+ } else {
913
+ this.log('retrieveOktaAccessToken response failure')
881
914
  }
882
915
  } else {
883
916
  return this.data.oktaAccessToken
@@ -912,8 +945,11 @@ class sessionClass {
912
945
  this.data.authnSessionToken = response.sessionToken
913
946
  this.save_session_data()
914
947
  return this.data.authnSessionToken
948
+ } else {
949
+ this.log('getAuthnSessionToken response failure')
915
950
  }
916
951
  } else {
952
+ this.debuglog('using cached authnSessionToken')
917
953
  return this.data.authnSessionToken
918
954
  }
919
955
  }
@@ -1066,7 +1102,7 @@ class sessionClass {
1066
1102
  // finally save the setting
1067
1103
  this.setHighlightsCacheExpiry(cache_name, cacheExpiry)
1068
1104
  } else {
1069
- this.log('error : invalid json from url ' + getObj.url)
1105
+ this.log('error : invalid json from url ' + reqObj.url)
1070
1106
  }
1071
1107
  } else {
1072
1108
  this.debuglog('using cached highlight data')
@@ -1099,10 +1135,12 @@ class sessionClass {
1099
1135
  let cache_name = dateString
1100
1136
  let url = 'https://bdfed.stitch.mlbinfra.com/bdfed/transform-mlb-scoreboard?stitch_env=prod&sortTemplate=2&sportId=1&sportId=17&startDate=' + dateString + '&endDate=' + dateString + '&gameType=E&&gameType=S&&gameType=R&&gameType=F&&gameType=D&&gameType=L&&gameType=W&&gameType=A&language=en&leagueId=104&leagueId=103&leagueId=131&contextTeamId='
1101
1137
  if ( team ) {
1138
+ this.debuglog('getDayData for team ' + cache_name + ' on date ' + dateString)
1102
1139
  cache_name = team.toUpperCase() + dateString
1103
1140
  url = 'http://statsapi.mlb.com/api/v1/schedule?sportId=1&teamId=' + TEAM_IDS[team.toUpperCase()] + '&startDate=' + dateString + '&endDate=' + dateString + '&gameType=&gamePk=&hydrate=team,game(content(media(epg)))'
1141
+ } else {
1142
+ this.debuglog('getDayData for date ' + dateString)
1104
1143
  }
1105
- this.debuglog('getDayData for ' + cache_name)
1106
1144
  let cache_file = path.join(CACHE_DIRECTORY, cache_name+'.json')
1107
1145
  let currentDate = new Date()
1108
1146
  if ( !fs.existsSync(cache_file) || !this.cache || !this.cache.dates || !this.cache.dates[cache_name] || !this.cache.dates[cache_name].dateCacheExpiry || (currentDate > new Date(this.cache.dates[cache_name].dateCacheExpiry)) ) {
@@ -1162,7 +1200,7 @@ class sessionClass {
1162
1200
  // finally save the setting
1163
1201
  this.setDateCacheExpiry(cache_name, cacheExpiry)
1164
1202
  } else {
1165
- this.log('error : invalid json from url ' + getObj.url)
1203
+ this.log('error : invalid json from url ' + reqObj.url)
1166
1204
  }
1167
1205
  } else {
1168
1206
  this.debuglog('using cached date data')
@@ -1215,7 +1253,7 @@ class sessionClass {
1215
1253
  this.cache.weekCacheExpiry = nextDate
1216
1254
  this.save_cache_data()
1217
1255
  } else {
1218
- this.log('error : invalid json from url ' + getObj.url)
1256
+ this.log('error : invalid json from url ' + reqObj.url)
1219
1257
  }
1220
1258
  } else {
1221
1259
  this.debuglog('using cached channel data')
@@ -1834,7 +1872,7 @@ class sessionClass {
1834
1872
  // finally save the setting
1835
1873
  this.setAiringsCacheExpiry(cache_name, cacheExpiry)
1836
1874
  } else {
1837
- this.log('error : invalid json from url ' + getObj.url)
1875
+ this.log('error : invalid json from url ' + reqObj.url)
1838
1876
  }
1839
1877
  } else {
1840
1878
  this.debuglog('using cached airings data')
@@ -1897,7 +1935,7 @@ class sessionClass {
1897
1935
  // finally save the setting
1898
1936
  this.setGamedayCacheExpiry(cache_name, cacheExpiry)
1899
1937
  } else {
1900
- this.log('error : invalid response from url ' + getObj.url)
1938
+ this.log('error : invalid response from url ' + reqObj.url)
1901
1939
  }
1902
1940
  } else {
1903
1941
  this.debuglog('using cached gameday data')
@@ -2228,7 +2266,7 @@ class sessionClass {
2228
2266
 
2229
2267
  this.save_cache_data()
2230
2268
  } else {
2231
- this.log('error : invalid response from url ' + getObj.url)
2269
+ this.log('error : invalid response from url ' + reqObj.url)
2232
2270
  }
2233
2271
  } else {
2234
2272
  this.debuglog('using cached big inning schedule')
@@ -2280,7 +2318,7 @@ class sessionClass {
2280
2318
  this.cache.eventURLCacheExpiry = cacheExpiry
2281
2319
  this.save_cache_data()
2282
2320
  } else {
2283
- this.log('error : invalid json from url ' + getObj.url)
2321
+ this.log('error : invalid json from url ' + reqObj.url)
2284
2322
  return
2285
2323
  }
2286
2324
  } else {
@@ -2309,7 +2347,7 @@ class sessionClass {
2309
2347
  break
2310
2348
  } else {
2311
2349
  if ( cache_data.items[i].title ) {
2312
- if ( (eventName == 'BIGINNING') && (cache_data.items[i].title == 'LIVE NOW: MLB Big Inning') ) {
2350
+ if ( (eventName == 'BIGINNING') && cache_data.items[i].title.includes('LIVE NOW: MLB Big Inning') ) {
2313
2351
  this.debuglog('active big inning url')
2314
2352
  return cache_data.items[i].fields.url
2315
2353
  } else if ( cache_data.items[i].title.toUpperCase().endsWith(' VS. ' + eventName) ) {