···6060import { rehydratePlay } from "../utils/CacheUtils.js";
6161import { findAsyncSequential, staggerMapper } from "../utils/AsyncUtils.js";
6262import pMap, { pMapIterable } from "p-map";
6363-import { comparePlayArtistsNormalized, comparePlayTracksNormalized, lifecyclelessInvariantTransform } from "../utils/PlayComparisonUtils.js";
6363+import { comparePlayArtistsNormalized, comparePlayTracksNormalized, existingScrobble, ExistingScrobbleOpts } from "../utils/PlayComparisonUtils.js";
6464+import { lifecyclelessInvariantTransform } from "../../core/PlayUtils.js";
6465import { normalizeStr } from "../utils/StringUtils.js";
6566import prom, { Counter, Gauge } from 'prom-client';
6667import { ScrobbleSubmitError, SimpleError } from "../common/errors/MSErrors.js";
···110111 nowPlayingTaskInterval: number = 5000;
111112 npLogger: Logger;
112113 dupeLogger: Logger;
114114+115115+ existingScrobble: (playObjPre: PlayObject, existingScrobbles: PlayObject[], log?: boolean) => Promise<PlayMatchResult>
113116114117 declare config: CommonClientConfig;
115118···178181 this.queuedGauge = clientMetrics.queued;
179182 this.deadLetterGauge = clientMetrics.deadLetter;
180183 this.scrobbledCounter = clientMetrics.scrobbled;
184184+ const existingScrobbleOpts: ExistingScrobbleOpts = {
185185+ logger: this.dupeLogger,
186186+ transformRules: this.transformRules,
187187+ transformPlay: this.transformPlay,
188188+ existingSubmitted: this.findExistingSubmittedPlayObj
189189+ }
190190+ this.existingScrobble = (playObjPre: PlayObject, existingScrobbles: PlayObject[], log?: boolean) => existingScrobble(playObjPre, existingScrobbles, existingScrobbleOpts, log)
181191 }
182192183193 protected getIdentifier() {
···450460 });
451461452462 return [matchPlayDate, dtInvariantMatches];
453453- }
454454-455455- existingScrobble = async (playObjPre: PlayObject, existingScrobbles: PlayObject[], log: boolean = true): Promise<PlayMatchResult> => {
456456-457457- const result: PlayMatchResult = {
458458- match: false,
459459- score: 0,
460460- breakdowns: [],
461461- reason: 'No existing scrobble matched with a score higher than 0'
462462- };
463463-464464- const playObj = await this.transformPlay(playObjPre, TRANSFORM_HOOK.candidate);
465465- if(this.transformRules.compare?.candidate !== undefined) {
466466- result.transformedPlay = playObj;
467467- }
468468-469469- const tr = truncateStringToLength(27);
470470- const scoreTrackOpts: TrackStringOptions = {include: ['track', 'artist', 'time'], transformers: {track: (t: any, data, existing) => `${existing ? '- ': ''}${tr(t)}`}};
471471-472472- // return early if we don't care about checking existing
473473- if (false === this.checkExistingScrobbles) {
474474- this.dupeLogger.trace(`${capitalize(playObj.meta.source ?? 'Source')}: ${buildTrackString(playObj, scoreTrackOpts)} => No Match because existing scrobble check is FALSE`);
475475- result.reason = 'existing scrobble check is FALSE';
476476- return result;
477477- }
478478-479479- let existingScrobble;
480480-481481- // then check if we have already recorded this
482482- const [existingExactSubmitted, existingDataSubmitted = []] = await this.findExistingSubmittedPlayObj(playObjPre);
483483-484484- // if we have an submitted play with matching data and play date then we can just return the response from the original scrobble
485485- if (existingExactSubmitted !== undefined) {
486486- result.closestMatchedPlay = lifecyclelessInvariantTransform(existingExactSubmitted.play);
487487- result.score = 1;
488488- result.match = true;
489489- result.reason = 'Exact Match found in previously successfully scrobbled plays';
490490-491491- existingScrobble = existingExactSubmitted.scrobble;
492492- }
493493- // if not though then we need to check recent scrobbles from scrobble api.
494494- // this will be less accurate than checking existing submitted (obv) but will happen if backlogging or on a fresh server start
495495-496496- if (existingScrobble === undefined) {
497497-498498- // if no recent scrobbles found then assume we haven't submitted it
499499- // (either user doesnt want to check history or there is no history to check!)
500500- if (existingScrobbles.length === 0) {
501501- this.dupeLogger.trace(`${buildTrackString(playObj, scoreTrackOpts)} => No Match because no existing scrobbles returned from API`);
502502- result.reason = 'no recent scrobbles returned from API';
503503- return result;
504504- }
505505-506506- // only check for fuzzy if we know this play is NOT a repeat
507507- // otherwise we may get a false positive on the previously played track ending time == repeat start time
508508- // -- this is info we only know if play was generated from MS player so we can be reasonably sure
509509- //
510510- // OR if play was generated from a source that uses History (endpoint sources, lfm or lz history sources)
511511- // then we can be reasonably sure that our candidate play has an accurate timestamp and wouldn't fuzzy match a previous scrobble
512512- const looseTimeAccuracy = playObj.data.repeat || playObj.meta.sourceSOT === SOURCE_SOT.HISTORY ? [TA_DURING] : [TA_FUZZY, TA_DURING];
513513-514514-515515- existingScrobble = await findAsyncSequential(existingScrobbles, async (xPre) => {
516516-517517- const x = await this.transformPlay(xPre, TRANSFORM_HOOK.existing);
518518-519519- //const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse);
520520-521521-522522- const temporalComparison = comparePlayTemporally(x, playObj);
523523- let timeMatch = 0;
524524- if(hasAcceptableTemporalAccuracy(temporalComparison.match)) {
525525- timeMatch = 1;
526526- } else if(hasAcceptableTemporalAccuracy(temporalComparison.match, looseTimeAccuracy)) {
527527- timeMatch = 0.6;
528528- }
529529-530530- const [titleMatch, titleResults] = comparePlayTracksNormalized(x, playObj);
531531-532532- const [artistMatch, wholeMatches] = comparePlayArtistsNormalized(x, playObj);
533533-534534- let artistScore = ARTIST_WEIGHT * artistMatch;
535535- const titleScore = TITLE_WEIGHT * titleMatch;
536536- const timeScore = TIME_WEIGHT * timeMatch;
537537- //const referenceScore = REFERENCE_WEIGHT * (referenceMatch ? 1 : 0);
538538- let score = artistScore + titleScore + timeScore;
539539-540540- let artistWholeMatchBonus = 0;
541541- let artistBreakdown = `Artist: ${artistMatch.toFixed(2)} * ${ARTIST_WEIGHT} = ${artistScore.toFixed(2)}`;
542542-543543- if(score < 1 && timeMatch > 0 && titleMatch > 0.98 && artistMatch > 0.1 && wholeMatches > 0 && comparingMultipleArtists(x, playObj)) {
544544- // address scenario where:
545545- // * title is very close
546546- // * time falls within plausible dup range
547547- // * artist is not totally different
548548- // * AND score is still not high enough for a dup
549549- //
550550- // if we detect the plays have multiple artists and we have at least one whole match (stricter comparison than regular score)
551551- // then bump artist score a little to see if it gets it over the fence
552552- //
553553- // EX: Source: The Bongo Hop - Sonora @ 2023-09-28T10:54:06-04:00 => Closest Scrobble: Nidia Gongora / The Bongo Hop - Sonora @ 2023-09-28T10:59:34-04:00 => Score 0.83 => No Match
554554- // one play is only returning primary artist, and timestamp is at beginning instead of end of play
555555-556556- const scoreBonus = artistMatch * 0.5;
557557- const scoreGapBonus = (1 - artistMatch) * 0.75;
558558- // use the smallest bump or 0.1
559559- artistWholeMatchBonus = Math.max(scoreBonus, scoreGapBonus, 0.1);
560560- artistScore = (ARTIST_WEIGHT + 0.05) * (artistMatch + artistWholeMatchBonus);
561561- score = artistScore + titleScore + timeScore;
562562- artistBreakdown = `Artist: (${artistMatch.toFixed(2)} + Whole Match Bonus ${artistWholeMatchBonus.toFixed(2)}) * (${ARTIST_WEIGHT} + Whole Match Bonus 0.05) = ${artistScore.toFixed(2)}`;
563563- }
564564-565565- const scoreBreakdowns = [
566566- //`Reference: ${(referenceMatch ? 1 : 0)} * ${REFERENCE_WEIGHT} = ${referenceScore.toFixed(2)}`,
567567- artistBreakdown,
568568- `Title: ${titleMatch.toFixed(2)} * ${TITLE_WEIGHT} = ${titleScore.toFixed(2)}`,
569569- `Time: (${capitalize(temporalAccuracyToString(temporalComparison.match))}) ${timeMatch} * ${TIME_WEIGHT} = ${timeScore.toFixed(2)}`,
570570- `Time Detail => ${temporalPlayComparisonSummary(temporalComparison, x, playObj)}`,
571571- `Score ${score.toFixed(2)} => ${score >= DUP_SCORE_THRESHOLD ? 'Matched!' : 'No Match'}`
572572- ];
573573-574574- const confidence = `Score ${score.toFixed(2)} => ${score >= DUP_SCORE_THRESHOLD ? 'Matched!' : 'No Match'}`
575575-576576- if (result.score <= score && score > 0) {
577577- result.reason = confidence;
578578- result.closestMatchedPlay = x;
579579- result.match = score >= DUP_SCORE_THRESHOLD;
580580- result.breakdowns = scoreBreakdowns;
581581- result.score = score;
582582-583583- if(result.match === false && temporalComparison.match === TA_EXACT && score >= 0.90) {
584584- // if we have a score >= 90 and time is an exact match
585585- // it's likely the differences are due to source-scrobbler data presentation, or deficiencies,
586586- // rather than actually being unique
587587- // so force match in this instance
588588- result.match = true;
589589- result.reason = `Score ${score.toFixed(2)} is not greater than threshold (${DUP_SCORE_THRESHOLD}) but it is very close and timestamp is an exact match, vibe matching.`;
590590- }
591591- }
592592-593593- return score >= DUP_SCORE_THRESHOLD;
594594- });
595595- }
596596-597597- const closestScrobbleParts: string[] = [];
598598- if(result.closestMatchedPlay !== undefined) {
599599- closestScrobbleParts.push(`Closest Scrobble: ${buildTrackString(result.closestMatchedPlay, scoreTrackOpts)}`);
600600- }
601601- closestScrobbleParts.push(result.reason);
602602- let summaryStart = `${capitalize(playObj.meta.source ?? 'Source')}: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobbleParts.join(' => ')}`;
603603- const summary = `${summaryStart}${result.breakdowns.length > 0 ? `\n${result.breakdowns.join('\n')}` : ''}`
604604- result.summary = summary;
605605- if(log) {
606606- this.dupeLogger.trace(summary);
607607- }
608608- return result;
609463 }
610464611465 public scrobble = async (playObj: PlayObject, opts?: { delay?: number | false }): Promise<PlayObject> => {
···11+import { loggerTest } from "@foxxmd/logging";
22+import { assert, expect } from 'chai';
33+import clone from "clone";
44+import { describe, it } from 'mocha';
55+import { generatePlayWithLifecycle, playWithLifecycleScrobble } from "../../../core/tests/utils/fixtures.js";
66+77+describe('#PlayFixtures', function () {
88+99+ it('Generates a Play with lifecycle', async function () {
1010+ await assert.isFulfilled(playWithLifecycleScrobble(generatePlayWithLifecycle()))
1111+ });
1212+1313+});
+183-22
src/backend/utils/PlayComparisonUtils.ts
···11import { getListDiff, ListDiff } from "@donedeal0/superdiff";
22-import { PlayObject, PlayObjectLifecycleless, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_EXACT, TemporalAccuracy } from "../../core/Atomic.js";
33-import { buildTrackString } from "../../core/StringUtils.js";
44-import { playObjDataMatch, setIntersection } from "../utils.js";
55-import { comparePlayTemporally, hasAcceptableTemporalAccuracy, TemporalPlayComparisonOptions } from "./TimeUtils.js";
22+import { PlayMatchResult, PlayObject, PlayObjectLifecycleless, SOURCE_SOT, TA_CLOSE, TA_DEFAULT_ACCURACY, TA_DURING, TA_EXACT, TA_FUZZY, TemporalAccuracy, TrackStringOptions } from "../../core/Atomic.js";
33+import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js";
44+import { comparingMultipleArtists, playObjDataMatch, setIntersection } from "../utils.js";
55+import { comparePlayTemporally, hasAcceptableTemporalAccuracy, temporalAccuracyToString, TemporalPlayComparisonOptions, temporalPlayComparisonSummary } from "./TimeUtils.js";
66import { compareNormalizedStrings, compareScrobbleArtists, compareScrobbleTracks, compareTracks, normalizeStr, TrackSamenessResults } from "./StringUtils.js";
77-import { ARTIST_WEIGHT, TITLE_WEIGHT } from "../common/infrastructure/Atomic.js";
77+import { ARTIST_WEIGHT, DUP_SCORE_THRESHOLD, ScrobbledPlayObject, TIME_WEIGHT, TITLE_WEIGHT } from "../common/infrastructure/Atomic.js";
88import { StringSamenessResult } from "@foxxmd/string-sameness";
99import { Duration } from "dayjs/plugin/duration.js";
1010+import { PlayTransformRules, TRANSFORM_HOOK, TransformHook } from "../common/infrastructure/Transform.js";
1111+import { Logger } from "@foxxmd/logging";
1212+import { loggerNoop } from "../common/logging.js";
1313+import { lifecyclelessInvariantTransform } from "../../core/PlayUtils.js";
1414+import { findAsyncSequential } from "./AsyncUtils.js";
101511161217export const metaInvariantTransform = (play: PlayObject): PlayObjectLifecycleless => {
···5560 meta: {}
5661 }
5762}
5858-5959-export const lifecyclelessInvariantTransform = (play: PlayObject): PlayObjectLifecycleless => {
6060- const {
6161- meta: {
6262- lifecycle,
6363- ...rest
6464- } = {},
6565- } = play;
6666- return {
6767- ...play,
6868- meta: {
6969- ...rest
7070- }
7171- }
7272-}
7373-74637564export type PlayTransformer = (play: PlayObject) => PlayObjectLifecycleless;
7665export type ListTransformers = PlayTransformer[];
···382371383372export const playDateWithinDurationOfAny = (play: PlayObject, plays: PlayObject[], dur: Duration): PlayObject | undefined => {
384373 return plays.find(x => Math.abs(x.data.playDate.diff(play.data.playDate, 's')) <= dur.asSeconds());
385385-}374374+}
375375+376376+export interface ExistingScrobbleOpts {
377377+ transformPlay?: (play: PlayObject, hookType: TransformHook) => Promise<PlayObject>
378378+ existingSubmitted?: (play: PlayObject) => Promise<[ScrobbledPlayObject?, ScrobbledPlayObject[]?]>
379379+ transformRules?: PlayTransformRules
380380+ checkExistingScrobbles?: boolean
381381+ logger?: Logger
382382+}
383383+384384+export const existingScrobble = async (playObjPre: PlayObject, existingScrobbles: PlayObject[], opts: ExistingScrobbleOpts = {}, log?: boolean): Promise<PlayMatchResult> => {
385385+386386+ const {
387387+ transformPlay = (play, hook) => play,
388388+ existingSubmitted = (play) => [undefined, undefined],
389389+ transformRules,
390390+ checkExistingScrobbles = true,
391391+ logger = loggerNoop
392392+ } = opts;
393393+394394+ const result: PlayMatchResult = {
395395+ match: false,
396396+ score: 0,
397397+ breakdowns: [],
398398+ reason: 'No existing scrobble matched with a score higher than 0'
399399+ };
400400+401401+ const playObj = await transformPlay(playObjPre, TRANSFORM_HOOK.candidate);
402402+ if(transformRules?.compare?.candidate !== undefined) {
403403+ result.transformedPlay = playObj;
404404+ }
405405+406406+ const tr = truncateStringToLength(27);
407407+ const scoreTrackOpts: TrackStringOptions = {include: ['track', 'artist', 'time'], transformers: {track: (t: any, data, existing) => `${existing ? '- ': ''}${tr(t)}`}};
408408+409409+ // return early if we don't care about checking existing
410410+ if (false === checkExistingScrobbles) {
411411+ logger.trace(`${capitalize(playObj.meta.source ?? 'Source')}: ${buildTrackString(playObj, scoreTrackOpts)} => No Match because existing scrobble check is FALSE`);
412412+ result.reason = 'existing scrobble check is FALSE';
413413+ return result;
414414+ }
415415+416416+ let existingScrobble;
417417+418418+ // then check if we have already recorded this
419419+ const [existingExactSubmitted, existingDataSubmitted = []] = await existingSubmitted(playObjPre);
420420+421421+ // if we have an submitted play with matching data and play date then we can just return the response from the original scrobble
422422+ if (existingExactSubmitted !== undefined) {
423423+ result.closestMatchedPlay = lifecyclelessInvariantTransform(existingExactSubmitted.play);
424424+ result.score = 1;
425425+ result.match = true;
426426+ result.reason = 'Exact Match found in previously successfully scrobbled plays';
427427+428428+ existingScrobble = existingExactSubmitted.scrobble;
429429+ }
430430+ // if not though then we need to check recent scrobbles from scrobble api.
431431+ // this will be less accurate than checking existing submitted (obv) but will happen if backlogging or on a fresh server start
432432+433433+ if (existingScrobble === undefined) {
434434+435435+ // if no recent scrobbles found then assume we haven't submitted it
436436+ // (either user doesnt want to check history or there is no history to check!)
437437+ if (existingScrobbles.length === 0) {
438438+ logger.trace(`${buildTrackString(playObj, scoreTrackOpts)} => No Match because no existing scrobbles returned from API`);
439439+ result.reason = 'no recent scrobbles returned from API';
440440+ return result;
441441+ }
442442+443443+ // only check for fuzzy if we know this play is NOT a repeat
444444+ // otherwise we may get a false positive on the previously played track ending time == repeat start time
445445+ // -- this is info we only know if play was generated from MS player so we can be reasonably sure
446446+ //
447447+ // OR if play was generated from a source that uses History (endpoint sources, lfm or lz history sources)
448448+ // then we can be reasonably sure that our candidate play has an accurate timestamp and wouldn't fuzzy match a previous scrobble
449449+ const looseTimeAccuracy = playObj.data.repeat || playObj.meta.sourceSOT === SOURCE_SOT.HISTORY ? [TA_DURING] : [TA_FUZZY, TA_DURING];
450450+451451+452452+ existingScrobble = await findAsyncSequential(existingScrobbles, async (xPre) => {
453453+454454+ const x = await transformPlay(xPre, TRANSFORM_HOOK.existing);
455455+456456+ //const referenceMatch = referenceApiScrobbleResponse !== undefined && playObjDataMatch(x, referenceApiScrobbleResponse);
457457+458458+459459+ const temporalComparison = comparePlayTemporally(x, playObj);
460460+ let timeMatch = 0;
461461+ if(hasAcceptableTemporalAccuracy(temporalComparison.match)) {
462462+ timeMatch = 1;
463463+ } else if(hasAcceptableTemporalAccuracy(temporalComparison.match, looseTimeAccuracy)) {
464464+ timeMatch = 0.6;
465465+ }
466466+467467+ const [titleMatch, titleResults] = comparePlayTracksNormalized(x, playObj);
468468+469469+ const [artistMatch, wholeMatches] = comparePlayArtistsNormalized(x, playObj);
470470+471471+ let artistScore = ARTIST_WEIGHT * artistMatch;
472472+ const titleScore = TITLE_WEIGHT * titleMatch;
473473+ const timeScore = TIME_WEIGHT * timeMatch;
474474+ //const referenceScore = REFERENCE_WEIGHT * (referenceMatch ? 1 : 0);
475475+ let score = artistScore + titleScore + timeScore;
476476+477477+ let artistWholeMatchBonus = 0;
478478+ let artistBreakdown = `Artist: ${artistMatch.toFixed(2)} * ${ARTIST_WEIGHT} = ${artistScore.toFixed(2)}`;
479479+480480+ if(score < 1 && timeMatch > 0 && titleMatch > 0.98 && artistMatch > 0.1 && wholeMatches > 0 && comparingMultipleArtists(x, playObj)) {
481481+ // address scenario where:
482482+ // * title is very close
483483+ // * time falls within plausible dup range
484484+ // * artist is not totally different
485485+ // * AND score is still not high enough for a dup
486486+ //
487487+ // if we detect the plays have multiple artists and we have at least one whole match (stricter comparison than regular score)
488488+ // then bump artist score a little to see if it gets it over the fence
489489+ //
490490+ // EX: Source: The Bongo Hop - Sonora @ 2023-09-28T10:54:06-04:00 => Closest Scrobble: Nidia Gongora / The Bongo Hop - Sonora @ 2023-09-28T10:59:34-04:00 => Score 0.83 => No Match
491491+ // one play is only returning primary artist, and timestamp is at beginning instead of end of play
492492+493493+ const scoreBonus = artistMatch * 0.5;
494494+ const scoreGapBonus = (1 - artistMatch) * 0.75;
495495+ // use the smallest bump or 0.1
496496+ artistWholeMatchBonus = Math.max(scoreBonus, scoreGapBonus, 0.1);
497497+ artistScore = (ARTIST_WEIGHT + 0.05) * (artistMatch + artistWholeMatchBonus);
498498+ score = artistScore + titleScore + timeScore;
499499+ artistBreakdown = `Artist: (${artistMatch.toFixed(2)} + Whole Match Bonus ${artistWholeMatchBonus.toFixed(2)}) * (${ARTIST_WEIGHT} + Whole Match Bonus 0.05) = ${artistScore.toFixed(2)}`;
500500+ }
501501+502502+ const scoreBreakdowns = [
503503+ //`Reference: ${(referenceMatch ? 1 : 0)} * ${REFERENCE_WEIGHT} = ${referenceScore.toFixed(2)}`,
504504+ artistBreakdown,
505505+ `Title: ${titleMatch.toFixed(2)} * ${TITLE_WEIGHT} = ${titleScore.toFixed(2)}`,
506506+ `Time: (${capitalize(temporalAccuracyToString(temporalComparison.match))}) ${timeMatch} * ${TIME_WEIGHT} = ${timeScore.toFixed(2)}`,
507507+ `Time Detail => ${temporalPlayComparisonSummary(temporalComparison, x, playObj)}`,
508508+ `Score ${score.toFixed(2)} => ${score >= DUP_SCORE_THRESHOLD ? 'Matched!' : 'No Match'}`
509509+ ];
510510+511511+ const confidence = `Score ${score.toFixed(2)} => ${score >= DUP_SCORE_THRESHOLD ? 'Matched!' : 'No Match'}`
512512+513513+ if (result.score <= score && score > 0) {
514514+ result.reason = confidence;
515515+ result.closestMatchedPlay = x;
516516+ result.match = score >= DUP_SCORE_THRESHOLD;
517517+ result.breakdowns = scoreBreakdowns;
518518+ result.score = score;
519519+520520+ if(result.match === false && temporalComparison.match === TA_EXACT && score >= 0.90) {
521521+ // if we have a score >= 90 and time is an exact match
522522+ // it's likely the differences are due to source-scrobbler data presentation, or deficiencies,
523523+ // rather than actually being unique
524524+ // so force match in this instance
525525+ result.match = true;
526526+ result.reason = `Score ${score.toFixed(2)} is not greater than threshold (${DUP_SCORE_THRESHOLD}) but it is very close and timestamp is an exact match, vibe matching.`;
527527+ }
528528+ }
529529+530530+ return score >= DUP_SCORE_THRESHOLD;
531531+ });
532532+ }
533533+534534+ const closestScrobbleParts: string[] = [];
535535+ if(result.closestMatchedPlay !== undefined) {
536536+ closestScrobbleParts.push(`Closest Scrobble: ${buildTrackString(result.closestMatchedPlay, scoreTrackOpts)}`);
537537+ }
538538+ closestScrobbleParts.push(result.reason);
539539+ let summaryStart = `${capitalize(playObj.meta.source ?? 'Source')}: ${buildTrackString(playObj, scoreTrackOpts)} => ${closestScrobbleParts.join(' => ')}`;
540540+ const summary = `${summaryStart}${result.breakdowns.length > 0 ? `\n${result.breakdowns.join('\n')}` : ''}`
541541+ result.summary = summary;
542542+ if(log) {
543543+ logger.trace(summary);
544544+ }
545545+ return result;
546546+ }