diff --git a/README-CHANGES.xml b/README-CHANGES.xml index 963e98786..4656e8012 100644 --- a/README-CHANGES.xml +++ b/README-CHANGES.xml @@ -522,7 +522,7 @@ - + @@ -535,11 +535,44 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle.properties b/gradle.properties index dc47cfb45..84a294f12 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,7 @@ POM_SCM_CONNECTION=scm:git:git://github.com/ThePalaceProject/android-core POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/ThePalaceProject/android-core POM_SCM_URL=http://github.com/ThePalaceProject/android-core POM_URL=http://github.com/ThePalaceProject/android-core -VERSION_NAME=1.13.0-SNAPSHOT +VERSION_NAME=1.14.0-SNAPSHOT VERSION_CODE_BASE=70000 android.useAndroidX=true diff --git a/org.thepalaceproject.android.platform b/org.thepalaceproject.android.platform index 5ab9bfb36..657fff9c1 160000 --- a/org.thepalaceproject.android.platform +++ b/org.thepalaceproject.android.platform @@ -1 +1 @@ -Subproject commit 5ab9bfb3694e2aab138e3eee8595957179a998c3 +Subproject commit 657fff9c1794a6d2309c0d5400cce4c4dda40262 diff --git a/settings.gradle.kts b/settings.gradle.kts index 703bdd648..2f0f1b44d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -262,7 +262,6 @@ include(":simplified-buildconfig-api") include(":simplified-content-api") include(":simplified-crashlytics") include(":simplified-crashlytics-api") -include(":simplified-deeplinks-controller-api") include(":simplified-documents") include(":simplified-feeds-api") include(":simplified-files") diff --git a/simplified-accounts-json/src/main/java/org/nypl/simplified/accounts/json/AccountAuthenticationCredentialsStoreJSON.kt b/simplified-accounts-json/src/main/java/org/nypl/simplified/accounts/json/AccountAuthenticationCredentialsStoreJSON.kt index 372732659..9f0e9cdab 100644 --- a/simplified-accounts-json/src/main/java/org/nypl/simplified/accounts/json/AccountAuthenticationCredentialsStoreJSON.kt +++ b/simplified-accounts-json/src/main/java/org/nypl/simplified/accounts/json/AccountAuthenticationCredentialsStoreJSON.kt @@ -130,7 +130,7 @@ object AccountAuthenticationCredentialsStoreJSON { result[accountID] = AccountAuthenticationCredentialsJSON.deserializeFromJSON(credentials.get(key)) } catch (e: Exception) { - this.logger.error("error deserializing credential: ", e) + this.logger.debug("error deserializing credential: ", e) } } diff --git a/simplified-accounts-json/src/main/java/org/nypl/simplified/accounts/json/AccountPreferencesJSON.kt b/simplified-accounts-json/src/main/java/org/nypl/simplified/accounts/json/AccountPreferencesJSON.kt index 828bf52b2..2a519fde0 100644 --- a/simplified-accounts-json/src/main/java/org/nypl/simplified/accounts/json/AccountPreferencesJSON.kt +++ b/simplified-accounts-json/src/main/java/org/nypl/simplified/accounts/json/AccountPreferencesJSON.kt @@ -50,12 +50,12 @@ object AccountPreferencesJSON { try { results.add(UUID.fromString(JSONParserUtilities.checkString(item))) } catch (e: Exception) { - this.logger.error("unable to parse acknowledgement: ", e) + this.logger.debug("unable to parse acknowledgement: ", e) } } return results.toList() } catch (e: Exception) { - this.logger.error("unable to parse acknowledgements: ", e) + this.logger.debug("unable to parse acknowledgements: ", e) return emptyList() } } diff --git a/simplified-accounts-json/src/main/java/org/nypl/simplified/accounts/json/AccountProvidersJSON.kt b/simplified-accounts-json/src/main/java/org/nypl/simplified/accounts/json/AccountProvidersJSON.kt index e05517c54..d60d5c664 100644 --- a/simplified-accounts-json/src/main/java/org/nypl/simplified/accounts/json/AccountProvidersJSON.kt +++ b/simplified-accounts-json/src/main/java/org/nypl/simplified/accounts/json/AccountProvidersJSON.kt @@ -361,7 +361,7 @@ object AccountProvidersJSON { try { items.add(AnnouncementJSON.deserializeFromJSON(node)) } catch (e: Exception) { - this.logger.error("unable to parse announcement: ", e) + this.logger.debug("unable to parse announcement: ", e) } } items.toList() diff --git a/simplified-accounts-registry/src/main/java/org/nypl/simplified/accounts/registry/AccountProviderRegistry.kt b/simplified-accounts-registry/src/main/java/org/nypl/simplified/accounts/registry/AccountProviderRegistry.kt index 570fe6a45..139f03afe 100644 --- a/simplified-accounts-registry/src/main/java/org/nypl/simplified/accounts/registry/AccountProviderRegistry.kt +++ b/simplified-accounts-registry/src/main/java/org/nypl/simplified/accounts/registry/AccountProviderRegistry.kt @@ -218,7 +218,7 @@ class AccountProviderRegistry private constructor( ) return taskRecorder.finishFailure() } catch (e: Exception) { - this.logger.error("resolution exception: ", e) + this.logger.debug("resolution exception: ", e) val message = e.message ?: e.javaClass.canonicalName ?: "unknown" taskRecorder.currentStepFailedAppending( message = message, diff --git a/simplified-accounts-source-filebased/src/main/java/org/nypl/simplified/accounts/source/filebased/AccountProviderSourceFileBased.kt b/simplified-accounts-source-filebased/src/main/java/org/nypl/simplified/accounts/source/filebased/AccountProviderSourceFileBased.kt index 7879787c4..f7de7c1d7 100644 --- a/simplified-accounts-source-filebased/src/main/java/org/nypl/simplified/accounts/source/filebased/AccountProviderSourceFileBased.kt +++ b/simplified-accounts-source-filebased/src/main/java/org/nypl/simplified/accounts/source/filebased/AccountProviderSourceFileBased.kt @@ -47,7 +47,7 @@ class AccountProviderSourceFileBased( SourceResult.SourceSucceeded(this.mapResult(newResult)) } } catch (e: Exception) { - this.logger.error("failed to load providers from file: ", e) + this.logger.debug("failed to load providers from file: ", e) SourceResult.SourceFailed(mapOf(), e) } } diff --git a/simplified-accounts-source-nyplregistry/src/main/java/org/nypl/simplified/accounts/source/nyplregistry/AccountProviderResolution.kt b/simplified-accounts-source-nyplregistry/src/main/java/org/nypl/simplified/accounts/source/nyplregistry/AccountProviderResolution.kt index 17bfbc34d..c63d6586b 100644 --- a/simplified-accounts-source-nyplregistry/src/main/java/org/nypl/simplified/accounts/source/nyplregistry/AccountProviderResolution.kt +++ b/simplified-accounts-source-nyplregistry/src/main/java/org/nypl/simplified/accounts/source/nyplregistry/AccountProviderResolution.kt @@ -139,7 +139,7 @@ class AccountProviderResolution( taskRecorder.finishSuccess(accountProvider) } catch (e: Exception) { - this.logger.error("failed to resolve account provider: ", e) + this.logger.debug("failed to resolve account provider: ", e) taskRecorder.currentStepFailedAppending( message = this.stringResources.resolvingUnexpectedException, errorCode = unexpectedException(this.description), diff --git a/simplified-accounts-source-nyplregistry/src/main/java/org/nypl/simplified/accounts/source/nyplregistry/AccountProviderSourceNYPLRegistry.kt b/simplified-accounts-source-nyplregistry/src/main/java/org/nypl/simplified/accounts/source/nyplregistry/AccountProviderSourceNYPLRegistry.kt index 31d376876..d624a72f7 100644 --- a/simplified-accounts-source-nyplregistry/src/main/java/org/nypl/simplified/accounts/source/nyplregistry/AccountProviderSourceNYPLRegistry.kt +++ b/simplified-accounts-source-nyplregistry/src/main/java/org/nypl/simplified/accounts/source/nyplregistry/AccountProviderSourceNYPLRegistry.kt @@ -110,7 +110,7 @@ class AccountProviderSourceNYPLRegistry( this.cacheServerResults(files, mergedResults) SourceResult.SourceSucceeded(mergedResults) } catch (e: Exception) { - this.logger.error("failed to fetch providers: ", e) + this.logger.debug("failed to fetch providers: ", e) SourceResult.SourceFailed(diskResults, e) } } diff --git a/simplified-analytics-api/src/main/java/org/nypl/simplified/analytics/api/Analytics.kt b/simplified-analytics-api/src/main/java/org/nypl/simplified/analytics/api/Analytics.kt index 23a1e5465..e3b122c0c 100644 --- a/simplified-analytics-api/src/main/java/org/nypl/simplified/analytics/api/Analytics.kt +++ b/simplified-analytics-api/src/main/java/org/nypl/simplified/analytics/api/Analytics.kt @@ -59,7 +59,7 @@ class Analytics private constructor( try { system.onAnalyticsEvent(event) } catch (e: Exception) { - this.logger.error("failed to publish analytics event: ", e) + this.logger.debug("failed to publish analytics event: ", e) } } } diff --git a/simplified-app-palace/build.gradle.kts b/simplified-app-palace/build.gradle.kts index 1eaef6550..45f3c4a65 100644 --- a/simplified-app-palace/build.gradle.kts +++ b/simplified-app-palace/build.gradle.kts @@ -270,7 +270,6 @@ dependencies { implementation(project(":simplified-content-api")) implementation(project(":simplified-crashlytics")) implementation(project(":simplified-crashlytics-api")) - implementation(project(":simplified-deeplinks-controller-api")) implementation(project(":simplified-documents")) implementation(project(":simplified-feeds-api")) implementation(project(":simplified-files")) @@ -497,6 +496,8 @@ dependencies { implementation(libs.androidx.webkit) implementation(libs.azam.ulidj) + implementation(libs.commons.compress) + implementation(libs.commons.io) implementation(libs.firebase.analytics) implementation(libs.firebase.annotations) implementation(libs.firebase.common) @@ -522,7 +523,9 @@ dependencies { implementation(libs.google.gson) implementation(libs.google.guava) implementation(libs.google.material) + implementation(libs.io7m.jattribute.core) implementation(libs.io7m.jfunctional) + implementation(libs.io7m.jmulticlose) implementation(libs.io7m.jnull) implementation(libs.irradia.fieldrush.api) implementation(libs.irradia.fieldrush.vanilla) @@ -575,6 +578,7 @@ dependencies { implementation(libs.palace.audiobook.manifest.parser.webpub) implementation(libs.palace.audiobook.media3) implementation(libs.palace.audiobook.parser.api) + implementation(libs.palace.audiobook.time.tracking) implementation(libs.palace.audiobook.views) implementation(libs.palace.drm.core) implementation(libs.palace.http.api) diff --git a/simplified-bookmarks-api/build.gradle.kts b/simplified-bookmarks-api/build.gradle.kts index 46756a88d..535a81e6a 100644 --- a/simplified-bookmarks-api/build.gradle.kts +++ b/simplified-bookmarks-api/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(libs.kotlin.stdlib) implementation(libs.palace.audiobook.api) implementation(libs.rxjava2) + implementation(libs.slf4j) compileOnly(libs.google.auto.value) annotationProcessor(libs.google.auto.value.processor) diff --git a/simplified-bookmarks-api/src/main/java/org/nypl/simplified/bookmarks/api/BookmarkAnnotations.kt b/simplified-bookmarks-api/src/main/java/org/nypl/simplified/bookmarks/api/BookmarkAnnotations.kt index 7da051d8a..9e0fdfb79 100644 --- a/simplified-bookmarks-api/src/main/java/org/nypl/simplified/bookmarks/api/BookmarkAnnotations.kt +++ b/simplified-bookmarks-api/src/main/java/org/nypl/simplified/bookmarks/api/BookmarkAnnotations.kt @@ -5,7 +5,7 @@ import org.joda.time.format.DateTimeFormat import org.joda.time.format.ISODateTimeFormat import org.nypl.simplified.books.api.bookmark.BookmarkKind import org.nypl.simplified.books.api.bookmark.SerializedBookmark -import org.nypl.simplified.books.api.bookmark.SerializedBookmark20210828 +import org.nypl.simplified.books.api.bookmark.SerializedBookmark20240424 import org.nypl.simplified.books.api.bookmark.SerializedLocators import java.net.URI @@ -108,7 +108,7 @@ object BookmarkAnnotations { val location = SerializedLocators.parseLocator(objectMapper.readTree(annotation.target.selector.value)) - return SerializedBookmark20210828( + return SerializedBookmark20240424( deviceID = annotation.body.device, kind = annotation.kind, location = location, diff --git a/simplified-bookmarks-api/src/main/java/org/nypl/simplified/bookmarks/api/BookmarkAnnotationsJSON.kt b/simplified-bookmarks-api/src/main/java/org/nypl/simplified/bookmarks/api/BookmarkAnnotationsJSON.kt index 38a4f8ddf..d0380abbd 100644 --- a/simplified-bookmarks-api/src/main/java/org/nypl/simplified/bookmarks/api/BookmarkAnnotationsJSON.kt +++ b/simplified-bookmarks-api/src/main/java/org/nypl/simplified/bookmarks/api/BookmarkAnnotationsJSON.kt @@ -1,6 +1,5 @@ package org.nypl.simplified.bookmarks.api -import android.util.Log import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.SerializationFeature @@ -11,9 +10,13 @@ import com.io7m.jfunctional.Some import org.nypl.simplified.books.api.bookmark.SerializedLocators import org.nypl.simplified.json.core.JSONParseException import org.nypl.simplified.json.core.JSONParserUtilities +import org.slf4j.LoggerFactory object BookmarkAnnotationsJSON { + private val logger = + LoggerFactory.getLogger(BookmarkAnnotationsJSON::class.java) + @Throws(JSONParseException::class) fun deserializeSelectorNodeFromJSON( objectMapper: ObjectMapper, @@ -224,7 +227,7 @@ object BookmarkAnnotationsJSON { ) bookmarkAnnotations.add(bookmarkAnnotation) } catch (exception: JSONParseException) { - Log.d("BookmarkAnnotationsJSON", "Error deserializing bookmark annotation") + this.logger.debug("Error deserializing bookmark annotation: ", exception) } } diff --git a/simplified-bookmarks/src/main/java/org/nypl/simplified/bookmarks/internal/BService.kt b/simplified-bookmarks/src/main/java/org/nypl/simplified/bookmarks/internal/BService.kt index f6d63f8f4..918dda078 100644 --- a/simplified-bookmarks/src/main/java/org/nypl/simplified/bookmarks/internal/BService.kt +++ b/simplified-bookmarks/src/main/java/org/nypl/simplified/bookmarks/internal/BService.kt @@ -131,7 +131,7 @@ class BService( ) ) } catch (e: Throwable) { - this.logger.error("sync: unable to sync profile: ", e) + this.logger.debug("sync: unable to sync profile: ", e) this.failedFuture(e) } } @@ -179,7 +179,7 @@ class BService( ) ) } catch (e: Throwable) { - this.logger.error("sync: unable to sync account: ", e) + this.logger.debug("sync: unable to sync account: ", e) this.failedFuture(e) } } @@ -207,7 +207,7 @@ class BService( ) ) } catch (e: Throwable) { - this.logger.error("bookmarkLoad: ", e) + this.logger.debug("bookmarkLoad: ", e) this.failedFuture(e) } } @@ -227,7 +227,7 @@ class BService( ) ) } catch (e: Throwable) { - this.logger.error("bookmarkCreateLocal: ", e) + this.logger.debug("bookmarkCreateLocal: ", e) this.failedFuture(e) } } @@ -248,7 +248,7 @@ class BService( ) ) } catch (e: Throwable) { - this.logger.error("bookmarkCreateRemote: ", e) + this.logger.debug("bookmarkCreateRemote: ", e) this.failedFuture(e) } } @@ -272,7 +272,7 @@ class BService( ) ) } catch (e: Throwable) { - this.logger.error("bookmarkCreate: ", e) + this.logger.debug("bookmarkCreate: ", e) this.failedFuture(e) } } @@ -294,7 +294,7 @@ class BService( ) ) } catch (e: Throwable) { - this.logger.error("bookmarkLoad: ", e) + this.logger.debug("bookmarkLoad: ", e) this.failedFuture(e) } } diff --git a/simplified-bookmarks/src/main/java/org/nypl/simplified/bookmarks/internal/BServiceOpCreateLocalBookmark.kt b/simplified-bookmarks/src/main/java/org/nypl/simplified/bookmarks/internal/BServiceOpCreateLocalBookmark.kt index d7fb1e07c..b16bcf259 100644 --- a/simplified-bookmarks/src/main/java/org/nypl/simplified/bookmarks/internal/BServiceOpCreateLocalBookmark.kt +++ b/simplified-bookmarks/src/main/java/org/nypl/simplified/bookmarks/internal/BServiceOpCreateLocalBookmark.kt @@ -53,7 +53,7 @@ internal class BServiceOpCreateLocalBookmark( this.bookmark } catch (e: Exception) { - this.logger.error("error saving bookmark locally: ", e) + this.logger.debug("error saving bookmark locally: ", e) throw e } } diff --git a/simplified-bookmarks/src/main/java/org/nypl/simplified/bookmarks/internal/BServiceOpCreateRemoteBookmark.kt b/simplified-bookmarks/src/main/java/org/nypl/simplified/bookmarks/internal/BServiceOpCreateRemoteBookmark.kt index b07508f87..a47c1e0aa 100644 --- a/simplified-bookmarks/src/main/java/org/nypl/simplified/bookmarks/internal/BServiceOpCreateRemoteBookmark.kt +++ b/simplified-bookmarks/src/main/java/org/nypl/simplified/bookmarks/internal/BServiceOpCreateRemoteBookmark.kt @@ -57,7 +57,7 @@ internal class BServiceOpCreateRemoteBookmark( return this.bookmark.withURI(bookmarkUri) } catch (e: Exception) { - this.logger.error("error sending bookmark: ", e) + this.logger.debug("error sending bookmark: ", e) throw e } } diff --git a/simplified-bookmarks/src/main/java/org/nypl/simplified/bookmarks/internal/BServiceOpDeleteBookmark.kt b/simplified-bookmarks/src/main/java/org/nypl/simplified/bookmarks/internal/BServiceOpDeleteBookmark.kt index 0199cba4f..6a66b1ca5 100644 --- a/simplified-bookmarks/src/main/java/org/nypl/simplified/bookmarks/internal/BServiceOpDeleteBookmark.kt +++ b/simplified-bookmarks/src/main/java/org/nypl/simplified/bookmarks/internal/BServiceOpDeleteBookmark.kt @@ -46,39 +46,79 @@ internal class BServiceOpDeleteBookmark( this.bookmark.bookmarkId.value ) - val bookmarkURI = this.bookmark.uri - if (bookmarkURI == null) { - this.logger.debug( - "[{}]: cannot remotely delete bookmark {} because it has no URI", - this.profile.id.uuid, - this.bookmark.bookmarkId.value - ) - throw IllegalStateException("Bookmark has no URI.") - } + if (this.bookmark.kind == BookmarkKind.BookmarkExplicit) { + var bookmarkURI = this.bookmark.uri + if (bookmarkURI == null) { + val bookmarkEquivalent = findEquivalentBookmark() + if (bookmarkEquivalent == null) { + this.logger.debug( + "[{}]: cannot remotely delete bookmark {} because it has no URI", + this.profile.id.uuid, + this.bookmark.bookmarkId.value + ) + throw IllegalStateException("Bookmark has no URI.") + } + bookmarkURI = bookmarkEquivalent.uri!! + } - val account = this.profile.account(this.accountID) - val syncInfo = BSyncableAccount.ofAccount(account) - if (syncInfo == null) { - this.logger.debug( - "[{}]: cannot remotely delete bookmark {} because the account is not syncable", - this.profile.id.uuid, - this.bookmark.bookmarkId.value + val account = this.profile.account(this.accountID) + val syncInfo = BSyncableAccount.ofAccount(account) + if (syncInfo == null) { + this.logger.debug( + "[{}]: cannot remotely delete bookmark {} because the account is not syncable", + this.profile.id.uuid, + this.bookmark.bookmarkId.value + ) + throw IllegalStateException("Account is not syncable.") + } + + this.httpCalls.bookmarkDelete( + account = account, + bookmarkURI = bookmarkURI, + credentials = syncInfo.credentials ) - throw IllegalStateException("Account is not syncable.") + Unit + } else { + Unit } - - this.httpCalls.bookmarkDelete( - account = account, - bookmarkURI = bookmarkURI, - credentials = syncInfo.credentials - ) - Unit } catch (e: Exception) { this.logger.error("[{}]: error deleting bookmark: ", this.profile.id.uuid, e) throw e } } + /** + * If we've handed the bookmark service a bookmark that doesn't have a URI in it, then + * we won't be able to delete it from the server. We might, however, have a bookmark + * that's equivalent to this bookmark that _does_ have a server URI in it. If we do, then + * use _that_ bookmark to perform the server-side deletion. A bookmarking system with a + * sane (read: non-W3C) design would have given bookmarks unique identifiers. + */ + + private fun findEquivalentBookmark(): SerializedBookmark? { + val account = this.profile.account(this.accountID) + val books = account.bookDatabase + val entry = books.entry(this.bookmark.book) + + for (handle in entry.formatHandles) { + for (possiblyEquivalentBookmark in handle.format.bookmarks) { + if (this.bookmarksAreInterchangeable(this.bookmark, possiblyEquivalentBookmark)) { + return possiblyEquivalentBookmark + } + } + } + return null + } + + private fun bookmarksAreInterchangeable( + bookmarkA: SerializedBookmark, + bookmarkB: SerializedBookmark + ): Boolean { + return (bookmarkA.kind == bookmarkB.kind) && + (bookmarkA.book == bookmarkB.book) && + (bookmarkA.location == bookmarkB.location) + } + private fun locallyDeleteBookmark() { try { this.logger.debug( @@ -95,6 +135,7 @@ internal class BServiceOpDeleteBookmark( when (this.bookmark.kind) { BookmarkKind.BookmarkLastReadLocation -> handle.setLastReadLocation(null) + BookmarkKind.BookmarkExplicit -> handle.deleteBookmark(this.bookmark.bookmarkId) } diff --git a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestRequest.kt b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestRequest.kt index e1ea8b631..32b61f432 100644 --- a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestRequest.kt +++ b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookManifestRequest.kt @@ -3,6 +3,7 @@ package org.nypl.simplified.books.audio import one.irradia.mime.api.MIMEType import org.librarysimplified.audiobook.api.PlayerUserAgent import org.librarysimplified.audiobook.license_check.spi.SingleLicenseCheckProviderType +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID import org.librarysimplified.audiobook.manifest_fulfill.api.ManifestFulfillmentStrategies import org.librarysimplified.audiobook.manifest_fulfill.api.ManifestFulfillmentStrategyRegistryType import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfilled @@ -26,6 +27,16 @@ data class AudioBookManifestRequest( /** * The HTTP client. + * The book's Palace ID for time tracking. This is essentially delivered as the book's ID + * value in the OPDS feed in which it was delivered. + */ + + val palaceID: PlayerPalaceID, + + /** + * The audio book file on disk, if one has been downloaded. This is used for packaged audio + * books, where the entire book is downloaded in one file. For unpackaged audio books, where + * only the manifest is downloaded, this will always be null. */ val httpClient: LSHTTPClientType, diff --git a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookStrategy.kt b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookStrategy.kt index d25454631..af6ea9725 100644 --- a/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookStrategy.kt +++ b/simplified-books-audio/src/main/java/org/nypl/simplified/books/audio/AudioBookStrategy.kt @@ -22,6 +22,7 @@ import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfilled import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentError import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentEvent import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfillmentStrategyType +import org.librarysimplified.audiobook.manifest_parser.api.ManifestUnparsed import org.librarysimplified.audiobook.parser.api.ParseError import org.librarysimplified.audiobook.parser.api.ParseResult import org.librarysimplified.audiobook.parser.api.ParseWarning @@ -370,7 +371,7 @@ class AudioBookStrategy( this.taskRecorder.beginNewStep("Parsing manifest.") return when (val result = this.request.manifestParsers.parse( uri = source ?: URI.create("urn:unavailable"), - streams = manifestBytes, + input = ManifestUnparsed(this.request.palaceID, manifestBytes), extensions = this.request.extensions )) { is ParseResult.Failure -> { diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowAudioBook.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowAudioBook.kt index 91dcae560..d81075987 100644 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowAudioBook.kt +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowAudioBook.kt @@ -4,6 +4,7 @@ import com.io7m.junreachable.UnreachableCodeException import one.irradia.mime.api.MIMECompatibility import one.irradia.mime.api.MIMEType import org.librarysimplified.audiobook.api.PlayerUserAgent +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID import org.nypl.simplified.accounts.api.AccountReadableType import org.nypl.simplified.books.audio.AudioBookLink import org.nypl.simplified.books.audio.AudioBookManifestRequest @@ -93,6 +94,7 @@ class BorrowAudioBook private constructor() : BorrowSubtaskType { contentType = context.currentAcquisitionPathElement.mimeType, credentials = context.account.loginState.credentials, httpClient = context.httpClient, + palaceID = PlayerPalaceID(context.bookCurrent.entry.id), services = context.services, target = AudioBookLink.Manifest(currentURI), userAgent = PlayerUserAgent(context.httpClient.userAgent()), diff --git a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCPAudiobook.kt b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCPAudiobook.kt index d90ad4065..f50654dd0 100644 --- a/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCPAudiobook.kt +++ b/simplified-books-borrowing/src/main/java/org/nypl/simplified/books/borrowing/internal/BorrowLCPAudiobook.kt @@ -4,6 +4,7 @@ import com.io7m.junreachable.UnreachableCodeException import one.irradia.mime.api.MIMECompatibility import one.irradia.mime.api.MIMEType import org.librarysimplified.audiobook.api.PlayerUserAgent +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID import org.nypl.simplified.accounts.api.AccountReadableType import org.nypl.simplified.books.api.BookDRMKind import org.nypl.simplified.books.audio.AudioBookLink @@ -91,6 +92,7 @@ class BorrowLCPAudiobook : BorrowSubtaskType { httpClient = context.httpClient, services = context.services, target = AudioBookLink.License(context.currentURICheck()), + palaceID = PlayerPalaceID(context.bookCurrent.entry.id), userAgent = PlayerUserAgent(context.httpClient.userAgent()), ) ) diff --git a/simplified-books-controller/build.gradle.kts b/simplified-books-controller/build.gradle.kts index 4cfa9e9a1..5db5a6dd2 100644 --- a/simplified-books-controller/build.gradle.kts +++ b/simplified-books-controller/build.gradle.kts @@ -16,7 +16,6 @@ dependencies { implementation(project(":simplified-books-preview")) implementation(project(":simplified-books-registry-api")) implementation(project(":simplified-crashlytics-api")) - implementation(project(":simplified-deeplinks-controller-api")) implementation(project(":simplified-feeds-api")) implementation(project(":simplified-files")) implementation(project(":simplified-futures")) diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/AbstractBookTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/AbstractBookTask.kt index 2ed6f6ba3..7b25ec39c 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/AbstractBookTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/AbstractBookTask.kt @@ -120,7 +120,7 @@ abstract class AbstractBookTask( val account = try { profile.account(accountID) } catch (e: Exception) { - this.logger.error("failed to find account: $accountID", e) + this.logger.debug("failed to find account: $accountID", e) this.taskRecorder.currentStepFailedAppending( message = "Failed to find account.", errorCode = BorrowErrorCodes.accountsDatabaseException, diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookSyncTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookSyncTask.kt index ffee910a9..866dffb8b 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookSyncTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/BookSyncTask.kt @@ -9,7 +9,6 @@ import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP.addCredentialsT import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP.getAccessToken import org.nypl.simplified.accounts.api.AccountAuthenticationCredentials import org.nypl.simplified.accounts.api.AccountID -import org.nypl.simplified.accounts.api.AccountLoginState import org.nypl.simplified.accounts.api.AccountProviderAuthenticationDescription import org.nypl.simplified.accounts.api.AccountProviderType import org.nypl.simplified.accounts.database.api.AccountType @@ -110,21 +109,20 @@ class BookSyncTask( this.taskRecorder.finishSuccess(Unit) } is LSHTTPResponseStatus.Responded.Error -> { - val recovered = this.onHTTPError(status, account) - - if (recovered) { - this.taskRecorder.finishSuccess(Unit) - } else { - val message = String.format("%s: %d: %s", provider.loansURI, status.properties.status, status.properties.message) - val exception = IOException(message) - this.taskRecorder.currentStepFailed( - message = message, - errorCode = "syncFailed", - exception = exception, - extraMessages = listOf() - ) - throw TaskFailedHandled(exception) - } + val message = String.format( + "%s: %d: %s", + provider.loansURI, + status.properties.status, + status.properties.message + ) + val exception = IOException(message) + this.taskRecorder.currentStepFailed( + message = message, + errorCode = "syncFailed", + exception = exception, + extraMessages = listOf() + ) + throw TaskFailedHandled(exception) } is LSHTTPResponseStatus.Failed -> throw IOException(status.exception) @@ -149,7 +147,7 @@ class BookSyncTask( this.withNewAnnotationsURI(it, profile) } } catch (e: Exception) { - this.logger.error("patron user profile: ", e) + this.logger.debug("patron user profile: ", e) } } @@ -201,7 +199,7 @@ class BookSyncTask( newProviderResult.result } is TaskResult.Failure -> { - this.logger.error("failed to resolve account provider: ", newProviderResult.exception) + this.logger.debug("failed to resolve account provider: ", newProviderResult.exception) oldProvider } } @@ -330,20 +328,4 @@ class BookSyncTask( throw IOException("No alternate link is available") } } - - /** - * Returns whether we recovered from the error. - */ - - private fun onHTTPError( - result: LSHTTPResponseStatus.Responded.Error, - account: AccountType - ): Boolean { - if (result.properties.status == 401) { - this.logger.debug("removing credentials due to 401 server response") - account.setLoginState(AccountLoginState.AccountNotLoggedIn) - return true - } - return false - } } diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/Controller.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/Controller.kt index 32f974285..0e4e9627f 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/Controller.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/Controller.kt @@ -10,7 +10,6 @@ import com.io7m.jfunctional.Some import com.io7m.junreachable.UnreachableCodeException import io.reactivex.Observable import io.reactivex.disposables.Disposable -import io.reactivex.subjects.BehaviorSubject import io.reactivex.subjects.Subject import org.joda.time.Instant import org.librarysimplified.http.api.LSHTTPClientType @@ -44,9 +43,6 @@ import org.nypl.simplified.books.formats.api.BookFormatSupportType import org.nypl.simplified.books.preview.BookPreviewRequirements import org.nypl.simplified.books.preview.BookPreviewTask import org.nypl.simplified.crashlytics.api.CrashlyticsServiceType -import org.nypl.simplified.deeplinks.controller.api.DeepLinkEvent -import org.nypl.simplified.deeplinks.controller.api.DeepLinksControllerType -import org.nypl.simplified.deeplinks.controller.api.ScreenID import org.nypl.simplified.feeds.api.Feed import org.nypl.simplified.feeds.api.FeedEntry import org.nypl.simplified.feeds.api.FeedLoaderType @@ -99,11 +95,7 @@ class Controller private constructor( private val taskExecutor: ListeningExecutorService ) : BooksControllerType, BooksPreviewControllerType, - ProfilesControllerType, - DeepLinksControllerType { - - private val deepLinkEventsObservable: BehaviorSubject = - BehaviorSubject.create() + ProfilesControllerType { private val borrows: ConcurrentHashMap @@ -223,7 +215,7 @@ class Controller private constructor( .keys .forEach { this.booksSync(it) } } catch (e: Exception) { - this.logger.error("failed to trigger book syncing: ", e) + this.logger.debug("failed to trigger book syncing: ", e) } this.updateCrashlytics() @@ -285,7 +277,7 @@ class Controller private constructor( try { future.set(task.invoke()) } catch (e: Throwable) { - this.logger.error("exception raised during task execution: ", e) + this.logger.debug("exception raised during task execution: ", e) future.setException(e) throw e } @@ -299,7 +291,7 @@ class Controller private constructor( try { future.set(task.call()) } catch (e: Throwable) { - this.logger.error("exception raised during task execution: ", e) + this.logger.debug("exception raised during task execution: ", e) future.setException(e) throw e } @@ -307,20 +299,6 @@ class Controller private constructor( return FluentFuture.from(future) } - override fun deepLinkEvents(): Observable { - return this.deepLinkEventsObservable - } - - override fun publishDeepLinkEvent(accountID: AccountID, screenID: ScreenID, barcode: String?) { - this.deepLinkEventsObservable.onNext( - DeepLinkEvent.DeepLinkIntercepted( - accountID = accountID, - screenID = screenID, - barcode = barcode - ) - ) - } - override fun profiles(): SortedMap { return this.castMap(this.profiles.profiles()) } diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateOrReturnExistingTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateOrReturnExistingTask.kt index d4b6a5994..b51a8ee2a 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateOrReturnExistingTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateOrReturnExistingTask.kt @@ -48,7 +48,7 @@ class ProfileAccountCreateOrReturnExistingTask( metrics = this.metrics ).call() } catch (e: Throwable) { - this.logger.error("account creation failed: ", e) + this.logger.debug("account creation failed: ", e) this.taskRecorder.currentStepFailedAppending( message = this.strings.unexpectedException, diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateTask.kt index 57887c8e3..c2539af1f 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountCreateTask.kt @@ -49,7 +49,7 @@ class ProfileAccountCreateTask( this.publishSuccessEvent(account) this.taskRecorder.finishSuccess(account) } catch (e: Throwable) { - this.logger.error("account creation failed: ", e) + this.logger.debug("account creation failed: ", e) this.taskRecorder.currentStepFailedAppending( message = this.strings.unexpectedException, diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountLoginTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountLoginTask.kt index b366649ba..234b18c4f 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountLoginTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountLoginTask.kt @@ -163,7 +163,7 @@ class ProfileAccountLoginTask( } } } catch (e: Throwable) { - this.logger.error("error during login process: ", e) + this.logger.debug("error during login process: ", e) this.steps.currentStepFailedAppending( message = this.loginStrings.loginUnexpectedException, errorCode = "unexpectedException", @@ -448,7 +448,7 @@ class ProfileAccountLoginTask( return try { node.get("accessToken").asText() } catch (e: Exception) { - this.logger.error("Error getting access token from basic token response: ", e) + this.logger.debug("Error getting access token from basic token response: ", e) throw e } } @@ -611,11 +611,11 @@ class ProfileAccountLoginTask( this.steps.currentStepSucceeded(this.loginStrings.loginDeviceActivated) } catch (e: ExecutionException) { val ex = e.cause!! - this.logger.error("exception raised waiting for adept future: ", ex) + this.logger.debug("exception raised waiting for adept future: ", ex) this.handleAdobeDRMConnectorException(ex) throw ex } catch (e: Throwable) { - this.logger.error("exception raised waiting for adept future: ", e) + this.logger.debug("exception raised waiting for adept future: ", e) this.handleAdobeDRMConnectorException(e) throw e } diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountLogoutTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountLogoutTask.kt index b49017f61..0e4042669 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountLogoutTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountLogoutTask.kt @@ -215,11 +215,11 @@ class ProfileAccountLogoutTask( adeptFuture.get(1L, TimeUnit.MINUTES) } catch (e: ExecutionException) { val ex = e.cause!! - this.logger.error("exception raised waiting for adept future: ", ex) + this.logger.debug("exception raised waiting for adept future: ", ex) this.handleAdobeDRMConnectorException(ex) throw ex } catch (e: Throwable) { - this.logger.error("exception raised waiting for adept future: ", e) + this.logger.debug("exception raised waiting for adept future: ", e) this.handleAdobeDRMConnectorException(e) throw e } diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountProviderUpdatedTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountProviderUpdatedTask.kt index 4068c2cfe..25c206752 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountProviderUpdatedTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileAccountProviderUpdatedTask.kt @@ -31,7 +31,7 @@ class ProfileAccountProviderUpdatedTask( } } } catch (e: Exception) { - this.logger.error("could not update account provider: ", e) + this.logger.debug("could not update account provider: ", e) } } } diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileDeletionTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileDeletionTask.kt index bfee53fef..f44aa506e 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileDeletionTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileDeletionTask.kt @@ -31,7 +31,7 @@ class ProfileDeletionTask( profile.delete() ProfileDeletionEvent.ProfileDeletionSucceeded(this.profileID) } catch (e: Exception) { - this.logger.error("failed to delete profile: ", e) + this.logger.debug("failed to delete profile: ", e) ProfileDeletionEvent.ProfileDeletionFailed(this.profileID, e) } } diff --git a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileUpdateTask.kt b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileUpdateTask.kt index fccf73b72..0b46161bd 100644 --- a/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileUpdateTask.kt +++ b/simplified-books-controller/src/main/java/org/nypl/simplified/books/controller/ProfileUpdateTask.kt @@ -47,7 +47,7 @@ class ProfileUpdateTask( this.events.onNext(event) return event } catch (e: Exception) { - this.logger.error("could not update profile: ", e) + this.logger.debug("could not update profile: ", e) val event = ProfileUpdated.Failed( profileID = this.requestedProfileId ?: ProfileID(UUID(0L, 0L)), diff --git a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/BookDRMInformationHandles.kt b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/BookDRMInformationHandles.kt index 994572d8a..07bac44a3 100644 --- a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/BookDRMInformationHandles.kt +++ b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/BookDRMInformationHandles.kt @@ -54,7 +54,7 @@ object BookDRMInformationHandles { try { FileUtilities.fileDelete(drmInfoFile) } catch (e: Exception) { - this.logger.error("unable to delete DRM file: ", e) + this.logger.debug("unable to delete DRM file: ", e) } when (createInitial) { diff --git a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/BookDatabase.kt b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/BookDatabase.kt index b6c097e6b..7c160dc70 100644 --- a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/BookDatabase.kt +++ b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/BookDatabase.kt @@ -192,7 +192,7 @@ class BookDatabase private constructor( ) if (errors.isNotEmpty()) { - errors.forEach { exception -> LOG.error("error opening book database: ", exception) } + errors.forEach { exception -> LOG.debug("error opening book database: ", exception) } throw BookDatabaseException( "One or more errors occurred whilst trying to open a book database.", errors ) diff --git a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandleAudioBook.kt b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandleAudioBook.kt index ac72557ea..4d65793c3 100644 --- a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandleAudioBook.kt +++ b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandleAudioBook.kt @@ -11,7 +11,9 @@ import org.librarysimplified.audiobook.api.PlayerBookSource import org.librarysimplified.audiobook.api.PlayerUserAgent import org.librarysimplified.audiobook.api.extensions.PlayerExtensionType import org.librarysimplified.audiobook.manifest.api.PlayerManifest +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID import org.librarysimplified.audiobook.manifest_parser.api.ManifestParsers +import org.librarysimplified.audiobook.manifest_parser.api.ManifestUnparsed import org.librarysimplified.audiobook.parser.api.ParseResult import org.nypl.simplified.books.api.BookDRMInformation import org.nypl.simplified.books.api.BookDRMKind @@ -169,7 +171,13 @@ internal class DatabaseFormatHandleAudioBook internal constructor( this.log.debug("[{}]: parsing audio book manifest", briefID) val manifestResult: ParseResult = - ManifestParsers.parse(this.fileManifest.toURI(), stream.readBytes()) + ManifestParsers.parse( + uri = this.fileManifest.toURI(), + input = ManifestUnparsed( + palaceId = PlayerPalaceID(this.parameters.entry.book.entry.id), + data = stream.readBytes() + ) + ) when (manifestResult) { is ParseResult.Failure -> { @@ -406,7 +414,7 @@ internal class DatabaseFormatHandleAudioBook internal constructor( try { this.loadLastReadLocation(fileLastRead = fileLastRead, bookmarkFallbackValues) } catch (e: Exception) { - this.logger.error("Failed to read the last-read location: ", e) + this.logger.debug("Failed to read the last-read location: ", e) null } } else { diff --git a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandleEPUB.kt b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandleEPUB.kt index 500764ab1..b38f876b4 100644 --- a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandleEPUB.kt +++ b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandleEPUB.kt @@ -309,7 +309,7 @@ internal class DatabaseFormatHandleEPUB internal constructor( try { this.loadLastReadLocation(fileLastRead = fileLastRead, bookmarkFallbackValues) } catch (e: Exception) { - this.logger.error("Failed to read the last-read location: ", e) + this.logger.debug("Failed to read the last-read location: ", e) null } } else { diff --git a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandlePDF.kt b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandlePDF.kt index 8631e88a5..9f5e3a8ea 100644 --- a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandlePDF.kt +++ b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/DatabaseFormatHandlePDF.kt @@ -299,7 +299,7 @@ internal class DatabaseFormatHandlePDF internal constructor( fallbackValues = fallbackValues, ) } catch (e: Exception) { - this.logger.error("Failed to read the last-read location: ", e) + this.logger.debug("Failed to read the last-read location: ", e) null } } diff --git a/simplified-books-preview/src/main/java/org/nypl/simplified/books/preview/BookPreviewAcquisitions.kt b/simplified-books-preview/src/main/java/org/nypl/simplified/books/preview/BookPreviewAcquisitions.kt index 83e515ebb..fe39597df 100644 --- a/simplified-books-preview/src/main/java/org/nypl/simplified/books/preview/BookPreviewAcquisitions.kt +++ b/simplified-books-preview/src/main/java/org/nypl/simplified/books/preview/BookPreviewAcquisitions.kt @@ -1,25 +1,20 @@ package org.nypl.simplified.books.preview import org.nypl.simplified.books.formats.api.StandardFormatNames -import org.nypl.simplified.books.formats.api.StandardFormatNames.textHtmlBook import org.nypl.simplified.opds.core.OPDSAcquisitionFeedEntry import org.nypl.simplified.opds.core.OPDSPreviewAcquisition object BookPreviewAcquisitions { /** - * Pick the preferred preview acquisition for the OPDS feed entry. + * Pick the preferred preview acquisition for the OPDS feed entry. We use the first supported + * acquisition, effectively allowing the server to set preferences. */ fun pickBestPreviewAcquisition( entry: OPDSAcquisitionFeedEntry ): OPDSPreviewAcquisition? { - // we try to see if there's a preview acquisition of the "text/html" MIME type and if there - // isn't one, we return the first preview acquisition that is on the list of supported book - // previews, if any, or null, if none. return entry.previewAcquisitions.firstOrNull { - it.type == textHtmlBook - } ?: entry.previewAcquisitions.firstOrNull { StandardFormatNames.bookPreviewFiles.contains(it.type) } } diff --git a/simplified-books-registry-api/src/main/java/org/nypl/simplified/books/book_registry/BookPreviewRegistry.kt b/simplified-books-registry-api/src/main/java/org/nypl/simplified/books/book_registry/BookPreviewRegistry.kt index 805d4b928..9bc8cf568 100644 --- a/simplified-books-registry-api/src/main/java/org/nypl/simplified/books/book_registry/BookPreviewRegistry.kt +++ b/simplified-books-registry-api/src/main/java/org/nypl/simplified/books/book_registry/BookPreviewRegistry.kt @@ -1,7 +1,7 @@ package org.nypl.simplified.books.book_registry import io.reactivex.Observable -import io.reactivex.subjects.PublishSubject +import io.reactivex.subjects.BehaviorSubject import org.slf4j.LoggerFactory import java.io.File @@ -11,8 +11,8 @@ class BookPreviewRegistry( private val logger = LoggerFactory.getLogger(BookPreviewRegistry::class.java) - private val bookPreviewStatusSubject: PublishSubject = - PublishSubject.create() + private val bookPreviewStatusSubject: BehaviorSubject = + BehaviorSubject.create() override fun observeBookPreviewStatus(): Observable { return this.bookPreviewStatusSubject diff --git a/simplified-books-time-tracking/README.md b/simplified-books-time-tracking/README.md index e93338578..f917056f9 100644 --- a/simplified-books-time-tracking/README.md +++ b/simplified-books-time-tracking/README.md @@ -4,3 +4,41 @@ org.librarysimplified.books.time.tracking The `org.librarysimplified.books.time.tracking` module provides all the code necessary to track the time a book is being read or listened to. +## Implementation + +The time tracking service is split into four components in order to make each step independently +testable and auditable. + +1. The audiobook player publishes time spans. A time span is started when the play button is + pressed in an audiobook, and ends after one minute or the pause button is pressed, whichever + comes first. If the pause button _wasn't_ pressed, a new time span is started. This effectively + means that the audiobook player publishes a stream of time spans as long as a book is playing. + Once published, the audiobook player doesn't care what happens to the spans. + +2. The _collector_ service subscribes to the audiobook player span stream and serializes each + span to disk in a single directory. Spans are written atomically by creating temporary files + and then atomically renaming the temporary files to a naming pattern recognized by the _merger_. + +3. The _merger_ service watches the directory written by the _collector_ and, roughly every + thirty seconds, reads every serialized time span that is older than ninety seconds. The + reason for having the age cutoff is that we want to be absolutely certain that no new spans + will arrive for a given minute before we merge them, so we have to be absolutely sure that + the "most recent minute" is over. The read spans are merged into time tracking entries that + the server expects and atomically written to an output directory. + +4. The _sender_ service watches the directory written by the _merger_ and reads time tracking + entries from the directory. It sends each entry to the server (batching the entries to + minimize the number of HTTP calls). For every entry the server claims to have accepted, the + corresponding entry file is deleted from the _merger_ output directory. Entries that were + not accepted are retried indefinitely. + +All operations are recorded into an append-only audit log that can be captured from the device +when sending error logs. + +Errors are logged to Crashlytics with the following attributes: + +|Name|Value| +|----|-----| +|`System`|`TimeTracking`| +|`SubSystem`|One of `Collector`, `Merger`, `Sender`| +|`TimeLoss`|Either `true` or `false` depending on whether the system thinks it lost a time tracking entry| diff --git a/simplified-books-time-tracking/build.gradle.kts b/simplified-books-time-tracking/build.gradle.kts index de45891df..43a4ce662 100644 --- a/simplified-books-time-tracking/build.gradle.kts +++ b/simplified-books-time-tracking/build.gradle.kts @@ -7,16 +7,20 @@ dependencies { implementation(project(":simplified-profiles-controller-api")) implementation(project(":simplified-services-api")) + implementation(libs.azam.ulidj) + implementation(libs.io7m.jmulticlose) + implementation(libs.io7m.jattribute.core) implementation(libs.irradia.mime.api) - implementation(libs.kotlinx.datetime) implementation(libs.jackson.core) implementation(libs.jackson.databind) implementation(libs.joda.time) implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.datetime) implementation(libs.palace.audiobook.api) + implementation(libs.palace.audiobook.manifest.api) + implementation(libs.palace.audiobook.time.tracking) implementation(libs.palace.http.api) implementation(libs.rxandroid2) implementation(libs.rxjava2) implementation(libs.slf4j) - implementation(libs.azam.ulidj) } diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingCollector.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingCollector.kt new file mode 100644 index 000000000..d7bd846b3 --- /dev/null +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingCollector.kt @@ -0,0 +1,195 @@ +package org.nypl.simplified.books.time.tracking + +import com.io7m.jattribute.core.AttributeReadableType +import com.io7m.jmulticlose.core.CloseableCollection +import io.reactivex.Observable +import org.librarysimplified.audiobook.time_tracking.PlayerTimeTracked +import org.nypl.simplified.profiles.controller.api.ProfilesControllerType +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.nio.file.StandardOpenOption.CREATE +import java.nio.file.StandardOpenOption.TRUNCATE_EXISTING +import java.nio.file.StandardOpenOption.WRITE +import java.time.ZoneOffset +import java.util.concurrent.Executors +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +/** + * The collector service. + * + * This listens for a stream of "time tracked" events and serializes them into a directory. It + * also records, for debugging purposes, when time tracking has "started" and "stopped" (ie, a + * book has opened or closed). + */ + +class TimeTrackingCollector private constructor( + private val profiles: ProfilesControllerType, + private val status: AttributeReadableType, + private val timeSegments: Observable, + private val debugDirectory: Path, + private val outputDirectory: Path, +) : TimeTrackingCollectorServiceType { + + private val logger = + LoggerFactory.getLogger(TimeTrackingCollector::class.java) + + private val awaitWrite = + LinkedBlockingQueue() + private val resources = + CloseableCollection.create() + + private val executor = + Executors.newSingleThreadExecutor { r -> + val thread = Thread(r) + thread.name = "org.nypl.simplified.books.time.tracking.collector[${thread.id}]" + thread.isDaemon = true + thread.priority = Thread.MIN_PRIORITY + thread + } + + init { + this.resources.add(AutoCloseable { + this.executor.shutdown() + this.executor.awaitTermination(30L, TimeUnit.SECONDS) + }) + val timeSubscription = this.timeSegments.subscribe(this::onTimeTrackedReceived) + this.resources.add(AutoCloseable { timeSubscription.dispose() }) + this.resources.add(this.status.subscribe(this::onStatusChanged)) + this.resources.add(AutoCloseable { this.awaitWrite.offer(Unit) }) + } + + private fun onTimeTrackedReceived( + time: PlayerTimeTracked + ) { + this.executor.execute { this.saveTimeTracked(time) } + } + + private fun saveTimeTracked( + time: PlayerTimeTracked + ) { + try { + MDC.put("System", "TimeTracking") + MDC.put("SubSystem", "Collector") + MDC.put("Book", time.bookTrackingId.value) + MDC.put("Seconds", time.seconds.toString()) + MDC.remove("TimeLoss") + + when (val statusNow = this.status.get()) { + is TimeTrackingStatus.Active -> { + val account = this.profiles.profileCurrent().account(statusNow.accountID) + + if (time.bookTrackingId != statusNow.bookId) { + MDC.put("TimeLoss", "true") + this.logger.warn( + "Time loss: Time tracking data received for book {}, but book {} is selected", + statusNow.bookId, + time.bookTrackingId + ) + return + } + + Files.createDirectories(this.outputDirectory) + + val outFile = + this.outputDirectory.resolve("${time.id}.ttspan") + val outFileTmp = + this.outputDirectory.resolve("${time.id}.ttspan.tmp") + + val utcStart = + time.timeStarted.withOffsetSameInstant(ZoneOffset.UTC) + val utcEnd = + time.timeEnded.withOffsetSameInstant(ZoneOffset.UTC) + + val span = + TimeTrackingReceivedSpan( + id = time.id, + accountID = statusNow.accountID, + libraryID = account.provider.id, + bookID = statusNow.bookId, + timeStarted = utcStart, + timeEnded = utcEnd, + targetURI = statusNow.timeTrackingUri + ) + + Files.newOutputStream(outFileTmp, WRITE, CREATE, TRUNCATE_EXISTING).use { s -> + span.toProperties().store(s, "") + s.flush() + } + Files.move(outFileTmp, outFile, ATOMIC_MOVE, REPLACE_EXISTING) + } + + TimeTrackingStatus.Inactive -> { + MDC.put("TimeLoss", "true") + this.logger.warn( + "Time tracking data received for book {}, but no book is selected", + time.bookTrackingId + ) + } + } + } catch (e: Throwable) { + MDC.put("TimeLoss", "true") + this.logger.warn("Failed to save time tracking information: ", e) + } finally { + this.awaitWrite.offer(Unit) + } + } + + companion object { + fun create( + profiles: ProfilesControllerType, + status: AttributeReadableType, + timeSegments: Observable, + debugDirectory: Path, + outputDirectory: Path, + ): TimeTrackingCollectorServiceType { + return TimeTrackingCollector( + profiles = profiles, + status = status, + timeSegments = timeSegments, + debugDirectory = debugDirectory, + outputDirectory = outputDirectory + ) + } + } + + private fun onStatusChanged( + oldValue: TimeTrackingStatus, + newValue: TimeTrackingStatus, + ) { + when (newValue) { + is TimeTrackingStatus.Active -> { + TimeTrackingDebugging.onTimeTrackingStarted( + timeTrackingDebugDirectory = this.debugDirectory.toFile(), + libraryId = newValue.libraryId, + bookId = newValue.bookId.value + ) + } + + TimeTrackingStatus.Inactive -> { + if (oldValue is TimeTrackingStatus.Active) { + TimeTrackingDebugging.onTimeTrackingStopped( + timeTrackingDebugDirectory = this.debugDirectory.toFile(), + libraryId = oldValue.libraryId, + bookId = oldValue.bookId.value + ) + } + } + } + } + + override fun awaitWrite( + timeout: Long, + unit: TimeUnit + ) { + this.awaitWrite.poll(timeout, unit) + } + + override fun close() { + this.resources.close() + } +} diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingCollectorServiceType.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingCollectorServiceType.kt new file mode 100644 index 000000000..c1c7d8ca2 --- /dev/null +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingCollectorServiceType.kt @@ -0,0 +1,20 @@ +package org.nypl.simplified.books.time.tracking + +import java.util.concurrent.TimeUnit + +/** + * The collector service. + * + * This listens for a stream of "time tracked" events and serializes them into a directory. It + * also records, for debugging purposes, when time tracking has "started" and "stopped" (ie, a + * book has opened or closed). + */ + +interface TimeTrackingCollectorServiceType : AutoCloseable { + + /** + * A testing method; the caller will block until the next write is completed. + */ + + fun awaitWrite(timeout: Long, unit: TimeUnit) +} diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingConnectivityListener.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingConnectivityListener.kt deleted file mode 100644 index e44d06761..000000000 --- a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingConnectivityListener.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.nypl.simplified.books.time.tracking - -import android.content.Context -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkRequest -import android.os.Build -import org.slf4j.LoggerFactory - -class TimeTrackingConnectivityListener( - private val context: Context, - private val onConnectivityStateRetrieved: (Boolean) -> Unit -) { - - private val logger = LoggerFactory.getLogger(TimeTrackingConnectivityListener::class.java) - - private val networkCallback = object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - onConnectivityStateRetrieved(true) - logger.debug("Connection available") - } - - override fun onLost(network: Network) { - onConnectivityStateRetrieved(false) - logger.debug("Connection lost") - } - - override fun onUnavailable() { - onConnectivityStateRetrieved(false) - logger.debug("Connection unavailable") - } - } - - init { - val connectivityManager = - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - connectivityManager.registerDefaultNetworkCallback(networkCallback) - } else { - connectivityManager.registerNetworkCallback( - NetworkRequest.Builder() - .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) - .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) - .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET) - .build(), - networkCallback - ) - } - } -} diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingDebugging.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingDebugging.kt new file mode 100644 index 000000000..156091db5 --- /dev/null +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingDebugging.kt @@ -0,0 +1,155 @@ +package org.nypl.simplified.books.time.tracking + +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import java.io.File +import java.io.FileOutputStream +import java.io.PrintWriter +import java.io.StringWriter +import java.time.OffsetTime +import java.util.Properties +import java.util.concurrent.locks.ReentrantLock + +object TimeTrackingDebugging { + + private val logger = + LoggerFactory.getLogger(TimeTrackingDebugging::class.java) + + private val separator = + ByteArray(2) + + private val fileLock = + ReentrantLock() + + init { + this.separator[0] = '\n'.code.toByte() + this.separator[1] = 0 + } + + private fun writeLocked( + directory: File, + properties: Properties + ) { + this.fileLock.lock() + + try { + val file = File(directory, "time_tracking_debug.dat") + FileOutputStream(file, true).use { stream -> + properties.storeToXML(stream, "") + stream.write(this.separator) + stream.flush() + } + } catch (e: Throwable) { + try { + MDC.put("TimeTracking", "true") + this.logger.error("Failed to log time tracking operation: ", e) + } finally { + MDC.remove("TimeTracking") + } + } finally { + this.fileLock.unlock() + } + } + + fun onTimeTrackingStarted( + timeTrackingDebugDirectory: File, + libraryId: String, + bookId: String + ) { + val p = Properties() + p.setProperty("Operation", "TimeTrackingStarted") + p.setProperty("Time", OffsetTime.now().toString()) + p.setProperty("LibraryID", libraryId) + p.setProperty("BookID", bookId) + this.writeLocked(timeTrackingDebugDirectory, p) + } + + fun onTimeTrackingStopped( + timeTrackingDebugDirectory: File, + libraryId: String, + bookId: String + ) { + val p = Properties() + p.setProperty("Operation", "TimeTrackingStopped") + p.setProperty("Time", OffsetTime.now().toString()) + p.setProperty("LibraryID", libraryId) + p.setProperty("BookID", bookId) + this.writeLocked(timeTrackingDebugDirectory, p) + } + + fun onTimeTrackingSendAttempt( + timeTrackingDebugDirectory: File, + libraryId: String, + bookId: String, + entryId: String, + seconds: Int + ) { + val p = Properties() + p.setProperty("Operation", "TimeTrackingSendAttempt") + p.setProperty("Time", OffsetTime.now().toString()) + p.setProperty("LibraryID", libraryId) + p.setProperty("BookID", bookId) + p.setProperty("EntryID", entryId) + p.setProperty("Seconds", seconds.toString()) + this.writeLocked(timeTrackingDebugDirectory, p) + } + + fun onTimeTrackingSendAttemptSucceeded( + timeTrackingDebugDirectory: File, + libraryId: String, + bookId: String, + entryId: String + ) { + val p = Properties() + p.setProperty("Operation", "TimeTrackingSendAttemptSucceeded") + p.setProperty("Time", OffsetTime.now().toString()) + p.setProperty("LibraryID", libraryId) + p.setProperty("BookID", bookId) + p.setProperty("EntryID", entryId) + this.writeLocked(timeTrackingDebugDirectory, p) + } + + fun onTimeTrackingSendAttemptFailedExceptionally( + timeTrackingDebugDirectory: File, + libraryId: String, + bookId: String, + entryId: String, + exception: Throwable + ) { + val p = Properties() + p.setProperty("Operation", "TimeTrackingSendAttemptFailedExceptionally") + p.setProperty("Time", OffsetTime.now().toString()) + p.setProperty("LibraryID", libraryId) + p.setProperty("BookID", bookId) + p.setProperty("EntryID", entryId) + p.setProperty("Exception", exceptionTextOf(exception)) + this.writeLocked(timeTrackingDebugDirectory, p) + } + + private fun exceptionTextOf( + exception: Throwable + ): String { + return StringWriter().use { stringWriter -> + PrintWriter(stringWriter).use { printWriter -> + exception.printStackTrace(printWriter) + printWriter.flush() + stringWriter.toString() + } + } + } + + fun onTimeTrackingSendAttemptFailed( + timeTrackingDebugDirectory: File, + libraryId: String, + bookId: String, + entryId: String + ) { + val p = Properties() + p.setProperty("Operation", "TimeTrackingSendAttemptFailed") + p.setProperty("Time", OffsetTime.now().toString()) + p.setProperty("LibraryID", libraryId) + p.setProperty("BookID", bookId) + p.setProperty("EntryID", entryId) + this.writeLocked(timeTrackingDebugDirectory, p) + } +} diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingEntry.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingEntry.kt index 03675a355..f20db6b24 100644 --- a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingEntry.kt +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingEntry.kt @@ -4,9 +4,4 @@ data class TimeTrackingEntry( val id: String, val duringMinute: String, val secondsPlayed: Int -) { - - fun isValidTimeEntry(): Boolean { - return secondsPlayed > 0 - } -} +) diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingEntryOutgoing.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingEntryOutgoing.kt new file mode 100644 index 000000000..d204f8cf8 --- /dev/null +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingEntryOutgoing.kt @@ -0,0 +1,86 @@ +package org.nypl.simplified.books.time.tracking + +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID +import org.nypl.simplified.accounts.api.AccountID +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.util.Properties +import java.util.UUID + +data class TimeTrackingEntryOutgoing( + val accountID: AccountID, + val libraryID: URI, + val bookID: PlayerPalaceID, + val targetURI: URI, + val timeEntry: TimeTrackingEntry, +) { + fun toProperties(): Properties { + val p = Properties() + p.setProperty("@Type", "TimeTrackingEntryOutgoing") + p.setProperty("@Version", "1") + p.setProperty("ID", this.timeEntry.id) + + p.setProperty("AccountID", this.accountID.uuid.toString()) + p.setProperty("BookID", this.bookID.value) + p.setProperty("LibraryID", this.libraryID.toString()) + p.setProperty("Minute", this.timeEntry.duringMinute) + p.setProperty("Seconds", this.timeEntry.secondsPlayed.toString()) + p.setProperty("TargetURI", this.targetURI.toString()) + return p + } + + data class Key( + val accountID: AccountID, + val bookID: PlayerPalaceID, + val libraryID: URI, + val targetURI: URI, + ) + + companion object { + + fun group( + entries: List + ): Map> { + val results = mutableMapOf>() + for (entry in entries) { + val key = Key( + accountID = entry.accountID, + bookID = entry.bookID, + libraryID = entry.libraryID, + targetURI = entry.targetURI + ) + var existing = results[key] + existing = existing?.plus(entry) ?: listOf(entry) + results[key] = existing + } + return results.toMap() + } + + fun ofFile( + file: Path + ): TimeTrackingEntryOutgoing { + return Files.newInputStream(file).use { stream -> + val p = Properties() + p.load(stream) + ofProperties(p) + } + } + + fun ofProperties( + p: Properties + ): TimeTrackingEntryOutgoing { + return TimeTrackingEntryOutgoing( + accountID = AccountID(UUID.fromString(p.getProperty("AccountID"))), + bookID = PlayerPalaceID(p.getProperty("BookID")), + targetURI = URI.create(p.getProperty("TargetURI")), + libraryID = URI.create(p.getProperty("LibraryID")), + timeEntry = TimeTrackingEntry( + id = p.getProperty("ID"), + duringMinute = p.getProperty("Minute"), + secondsPlayed = p.getProperty("Seconds").toInt() + ) + ) + } + } +} diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingHTTPCalls.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingHTTPCalls.kt index e4f8cd8a7..480df6baa 100644 --- a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingHTTPCalls.kt +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingHTTPCalls.kt @@ -1,7 +1,5 @@ package org.nypl.simplified.books.time.tracking -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.node.ObjectNode import one.irradia.mime.api.MIMEType import org.librarysimplified.http.api.LSHTTPClientType import org.librarysimplified.http.api.LSHTTPRequestBuilderType @@ -10,9 +8,7 @@ import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP.addCredentialsToProperties import org.nypl.simplified.accounts.api.AccountAuthenticatedHTTP.getAccessToken import org.nypl.simplified.accounts.database.api.AccountType -import org.nypl.simplified.crashlytics.api.CrashlyticsServiceType import org.slf4j.LoggerFactory -import java.io.ByteArrayInputStream import java.io.IOException import java.net.URI @@ -21,81 +17,44 @@ import java.net.URI */ class TimeTrackingHTTPCalls( - private val objectMapper: ObjectMapper, - private val http: LSHTTPClientType, - private val crashlytics: CrashlyticsServiceType? + private val http: LSHTTPClientType ) : TimeTrackingHTTPCallsType { - private val logger = LoggerFactory.getLogger(TimeTrackingHTTPCalls::class.java) + private val logger = + LoggerFactory.getLogger(TimeTrackingHTTPCalls::class.java) override fun registerTimeTrackingInfo( - timeTrackingInfo: TimeTrackingInfo, + request: TimeTrackingRequest, account: AccountType - ): List { - val credentials = account.loginState.credentials - - credentials ?: throw(Exception("Invalid Credentials")) + ): TimeTrackingServerResponse { + val credentials = + account.loginState.credentials ?: throw(Exception("Invalid Credentials")) val data = - TimeTrackingJSON.convertTimeTrackingInfoToBytes(this.objectMapper, timeTrackingInfo) + TimeTrackingJSON.serializeToBytes(request) val auth = AccountAuthenticatedHTTP.createAuthorization(credentials) val post = LSHTTPRequestBuilderType.Method.Post( data, MIMEType("application", "json", mapOf()) ) - val request = - this.http.newRequest(timeTrackingInfo.timeTrackingUri) + val httpRequest = + this.http.newRequest(request.timeTrackingUri) .setAuthorization(auth) .addCredentialsToProperties(credentials) .setMethod(post) .build() - return request.execute().use { response -> + return httpRequest.execute().use { response -> when (val status = response.status) { is LSHTTPResponseStatus.Responded.OK -> { account.updateBasicTokenCredentials(status.getAccessToken()) - - val timeTrackingResponse = TimeTrackingJSON.convertServerResponseToTimeTrackingResponse( - objectNode = objectMapper.readTree( - status.bodyStream ?: ByteArrayInputStream(ByteArray(0)) - ) as ObjectNode - ) - - val summary = timeTrackingResponse?.summary - val responses = timeTrackingResponse?.responses.orEmpty() - - logger.debug( - "Received time tracking summary: {} successes + {} failures = {} total", - summary?.successes, - summary?.failures, - summary?.total + TimeTrackingJSON.deserializeResponse( + status.bodyStream?.readBytes() ?: ByteArray(0) ) - - if (responses.isNotEmpty()) { - timeTrackingInfo.timeEntries.filter { timeEntry -> - val responseEntry = responses.firstOrNull { response -> - response.id == timeEntry.id - } ?: return@filter false - - val hasFailed = !responseEntry.isStatusSuccess() && !responseEntry.isStatusGone() - - if (!hasFailed) { - crashlytics?.log( - "Failed entry received from server: [id: ${responseEntry.id}, " + - "message: ${responseEntry.message}, status: ${responseEntry.status}]" - ) - } - - hasFailed - } - } else { - // return the original time entries if the server response has no response entries - timeTrackingInfo.timeEntries - } } is LSHTTPResponseStatus.Responded.Error -> { - logAndFail(timeTrackingInfo.timeTrackingUri, status) + logAndFail(request.timeTrackingUri, status) } is LSHTTPResponseStatus.Failed -> { throw status.exception diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingHTTPCallsType.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingHTTPCallsType.kt index 663398037..fdcbdf4cf 100644 --- a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingHTTPCallsType.kt +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingHTTPCallsType.kt @@ -7,7 +7,7 @@ interface TimeTrackingHTTPCallsType { @Throws(IOException::class) fun registerTimeTrackingInfo( - timeTrackingInfo: TimeTrackingInfo, + request: TimeTrackingRequest, account: AccountType - ): List + ): TimeTrackingServerResponse } diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingInfoFileUtils.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingInfoFileUtils.kt deleted file mode 100644 index 7b8ab4862..000000000 --- a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingInfoFileUtils.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.nypl.simplified.books.time.tracking - -import com.fasterxml.jackson.databind.ObjectMapper -import java.io.File - -object TimeTrackingInfoFileUtils { - - fun saveTimeTrackingInfoOnFile(timeTrackingInfo: TimeTrackingInfo, file: File) { - val json = TimeTrackingJSON.convertTimeTrackingToLocalJSON( - objectMapper = ObjectMapper(), - timeTrackingInfo = timeTrackingInfo - ) - - file.writeBytes(json.toString().toByteArray()) - } - - fun getTimeTrackingInfoFromFile(file: File): TimeTrackingInfo? { - return TimeTrackingJSON.convertBytesToTimeTrackingInfo( - bytes = file.readBytes() - ) - } - - fun addEntriesToFile(entries: List, file: File) { - val currentTimeTrackingInfo = getTimeTrackingInfoFromFile(file) ?: return - saveTimeTrackingInfoOnFile( - timeTrackingInfo = currentTimeTrackingInfo.copy( - timeEntries = ArrayList(currentTimeTrackingInfo.timeEntries).apply { - addAll(entries) - } - ), - file = file - ) - } -} diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingJSON.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingJSON.kt index a645cbde9..5f94b4247 100644 --- a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingJSON.kt +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingJSON.kt @@ -1,19 +1,12 @@ package org.nypl.simplified.books.time.tracking import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.SerializationFeature -import com.fasterxml.jackson.databind.node.ArrayNode -import com.fasterxml.jackson.databind.node.MissingNode import com.fasterxml.jackson.databind.node.ObjectNode +import org.nypl.simplified.json.core.JSONParseException import org.nypl.simplified.json.core.JSONParserUtilities -import org.slf4j.LoggerFactory -import java.net.URI object TimeTrackingJSON { - private val logger = LoggerFactory.getLogger(TimeTrackingJSON::class.java) - - private const val NODE_ACCOUNT_ID = "accountId" private const val NODE_BOOK_ID = "bookId" private const val NODE_DURING_MINUTE = "duringMinute" private const val NODE_FAILURES = "failures" @@ -27,127 +20,82 @@ object TimeTrackingJSON { private const val NODE_SUMMARY = "summary" private const val NODE_TIME_ENTRIES = "timeEntries" private const val NODE_TOTAL = "total" - private const val NODE_URI = "uri" - - private fun convertTimeTrackingToJSON( - objectMapper: ObjectMapper, - node: ObjectNode, - timeTrackingInfo: TimeTrackingInfo - ): ObjectNode { - node.put(NODE_BOOK_ID, timeTrackingInfo.bookId) - node.put(NODE_LIBRARY_ID, timeTrackingInfo.libraryId) - val timeEntriesArray = objectMapper.createArrayNode() + private val objectMapper = ObjectMapper() - timeTrackingInfo.timeEntries.forEach { entry -> - timeEntriesArray.add( - objectMapper.createObjectNode().apply { - put(NODE_ID, entry.id) - put(NODE_DURING_MINUTE, entry.duringMinute) - put(NODE_SECONDS_PLAYED, entry.secondsPlayed) - } - ) + fun serializeRequest( + request: TimeTrackingRequest + ): ObjectNode { + val node = this.objectMapper.createObjectNode() + node.put(this.NODE_BOOK_ID, request.bookId) + node.put(this.NODE_LIBRARY_ID, request.libraryId.toString()) + + val timeEntriesArray = node.putArray(this.NODE_TIME_ENTRIES) + request.timeEntries.forEach { entry -> + val entryNode = this.objectMapper.createObjectNode() + entryNode.put(this.NODE_ID, entry.id) + entryNode.put(this.NODE_DURING_MINUTE, entry.duringMinute) + entryNode.put(this.NODE_SECONDS_PLAYED, entry.secondsPlayed) + timeEntriesArray.add(entryNode) } - - node.set(NODE_TIME_ENTRIES, timeEntriesArray) - return node } - fun convertServerResponseToTimeTrackingResponse( - objectNode: ObjectNode, - ): TimeTrackingResponse? { - return try { - val responsesNode = JSONParserUtilities.getArray(objectNode, NODE_RESPONSES) - val responses = arrayListOf() - responsesNode.forEach { responseNode -> - responses.add( - TimeTrackingResponseEntry( - id = responseNode.get(NODE_ID).asText(), - message = responseNode.get(NODE_MESSAGE).asText(), - status = responseNode.get(NODE_STATUS).asInt() - ) - ) - } - - val summaryNode = objectNode.get(NODE_SUMMARY) - val summary = TimeTrackingResponseSummary( - failures = summaryNode.get(NODE_FAILURES).asInt(), - successes = summaryNode.get(NODE_SUCCESSES).asInt(), - total = summaryNode.get(NODE_TOTAL).asInt() - ) - - TimeTrackingResponse( - summary = summary, - responses = responses - ) - } catch (e: Exception) { - logger.error("Error converting server response to time tracking response: ", e) - null - } - } - - fun convertTimeTrackingToLocalJSON( - objectMapper: ObjectMapper, - timeTrackingInfo: TimeTrackingInfo - ): ObjectNode { - val node = objectMapper.createObjectNode() - node.put(NODE_ACCOUNT_ID, timeTrackingInfo.accountId) - node.put(NODE_URI, timeTrackingInfo.timeTrackingUri.toString()) - return convertTimeTrackingToJSON( - objectMapper = objectMapper, - node = node, - timeTrackingInfo = timeTrackingInfo - ) - } - - fun convertTimeTrackingInfoToBytes( - objectMapper: ObjectMapper, - timeTrackingInfo: TimeTrackingInfo + fun serializeToBytes( + request: TimeTrackingRequest ): ByteArray { - objectMapper.configure(SerializationFeature.INDENT_OUTPUT, false) - objectMapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true) - return objectMapper.writeValueAsBytes( - convertTimeTrackingToJSON( - objectMapper = objectMapper, - node = objectMapper.createObjectNode(), - timeTrackingInfo = timeTrackingInfo - ) - ) + return this.objectMapper.writeValueAsBytes(serializeRequest(request)) } - fun convertBytesToTimeTrackingInfo( - bytes: ByteArray - ): TimeTrackingInfo? { - return try { - val mapper = ObjectMapper() - val node = mapper.readTree(bytes) - - if (node is MissingNode) { - return null + @Throws(JSONParseException::class) + fun deserializeResponse( + data: ByteArray + ): TimeTrackingServerResponse { + val r = this.objectMapper.readTree(data) + return when (r) { + is ObjectNode -> { + deserializeResponseObject(r) } - val timeEntriesJSON = - JSONParserUtilities.getArray(node as ObjectNode, NODE_TIME_ENTRIES) - - val timeEntries = timeEntriesJSON.map { entry -> - TimeTrackingEntry( - id = entry.get(NODE_ID).asText(), - duringMinute = entry.get(NODE_DURING_MINUTE).asText(), - secondsPlayed = entry.get(NODE_SECONDS_PLAYED).asInt() + else -> { + throw JSONParseException( + "Server returned an unparseable JSON response (expected Object, but got ${r.javaClass})" ) } + } + } - TimeTrackingInfo( - accountId = node.get(NODE_ACCOUNT_ID).asText(), - bookId = node.get(NODE_BOOK_ID).asText(), - libraryId = node.get(NODE_LIBRARY_ID).asText(), - timeEntries = timeEntries, - timeTrackingUri = URI(node.get(NODE_URI).asText()) + @Throws(JSONParseException::class) + fun deserializeResponseObject( + o: ObjectNode + ): TimeTrackingServerResponse { + val responsesNode = + JSONParserUtilities.getArray(o, this.NODE_RESPONSES) + + val responses = arrayListOf() + responsesNode.forEach { responseNode -> + responses.add( + TimeTrackingServerResponseEntry( + id = responseNode.get(this.NODE_ID).asText(), + message = responseNode.get(this.NODE_MESSAGE).asText(), + status = responseNode.get(this.NODE_STATUS).asInt() + ) ) - } catch (e: Exception) { - logger.error("Error converting bytes from file: ", e) - null } + + val summaryNode = + JSONParserUtilities.getObject(o, this.NODE_SUMMARY) + + val summary = + TimeTrackingServerResponseSummary( + failures = JSONParserUtilities.getInteger(summaryNode, this.NODE_FAILURES), + successes = JSONParserUtilities.getInteger(summaryNode, this.NODE_SUCCESSES), + total = JSONParserUtilities.getInteger(summaryNode, this.NODE_TOTAL) + ) + + return TimeTrackingServerResponse( + summary = summary, + responses = responses.toList() + ) } } diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingMerge.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingMerge.kt new file mode 100644 index 000000000..f0a74a73b --- /dev/null +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingMerge.kt @@ -0,0 +1,286 @@ +package org.nypl.simplified.books.time.tracking + +import com.io7m.jmulticlose.core.CloseableCollection +import io.azam.ulidj.ULID +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID +import org.nypl.simplified.accounts.api.AccountID +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption.CREATE +import java.nio.file.StandardOpenOption.TRUNCATE_EXISTING +import java.nio.file.StandardOpenOption.WRITE +import java.time.Duration +import java.time.Instant +import java.time.OffsetDateTime +import java.util.concurrent.Executors +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.stream.Collectors + +/** + * The merge service. + * + * This takes spans of time that were serialized by the [TimeTrackingCollectorServiceType] and + * merges them into time tracking entries to be sent to the server. Necessarily, it only operates + * on spans that are over a minute old, because there might still be spans to come in the current + * minute. + */ + +class TimeTrackingMerge private constructor( + private val clock: () -> OffsetDateTime, + private val inboxDirectory: Path, + private val outboxDirectory: Path, + private val frequency: Duration, +) : TimeTrackingMergeServiceType { + + private val logger = + LoggerFactory.getLogger(TimeTrackingMerge::class.java) + private val resources = + CloseableCollection.create() + private val tickWait = + LinkedBlockingQueue(1) + + private val executor = + Executors.newSingleThreadScheduledExecutor { r -> + val thread = Thread(r) + thread.name = "org.nypl.simplified.books.time.tracking.merge[${thread.id}]" + thread.isDaemon = true + thread.priority = Thread.MIN_PRIORITY + thread + } + + init { + this.resources.add(AutoCloseable { + this.executor.shutdown() + this.executor.awaitTermination(30L, TimeUnit.SECONDS) + }) + + this.executor.scheduleWithFixedDelay( + { this.tick() }, + 0L, + this.frequency.toMillis(), + TimeUnit.MILLISECONDS + ) + + this.resources.add(AutoCloseable { this.tickWait.offer(Unit) }) + } + + private fun isSpanFileSuitable( + timeOldest: Instant, + file: Path + ): Boolean { + if (!Files.isRegularFile(file)) { + return false + } + if (!file.toString().endsWith(".ttspan")) { + return false + } + + val fileTime = Files.getLastModifiedTime(file).toInstant() + return fileTime.isBefore(timeOldest) + } + + private fun tick() { + try { + MDC.put("System", "TimeTracking") + MDC.put("SubSystem", "Merge") + + val timeNow = + this.clock.invoke() + .toInstant() + val timeOldest = + timeNow.minusSeconds(90L) + + /* + * Collect every non-temporary file with a modification date that shows that the file is + * at least 90 seconds old. By only inspecting files that are at least this old, we know + * that we won't receive any new spans that fell within the minutes of those spans. This + * means that we can safely merge them into single time tracking entries without the risk + * of losing any time. + */ + + val spanFiles: List = + Files.list(this.inboxDirectory) + .filter { p -> isSpanFileSuitable(timeOldest, p) } + .collect(Collectors.toList()) + + val spans = mutableListOf() + for (file in spanFiles) { + spans.add(TimeTrackingReceivedSpan.ofFile(file)) + } + + /* + * Merge the recorded spans into series of time tracking entries. This will, for example, + * merge spans that occurred within the same minute into a single time tracking entry, and + * split spans that crossed a minute boundary. Then, record each entry. + */ + + val entries = mergeEntries(spans) + for (entry in entries) { + this.writeEntry(entry) + } + + /* + * Now delete the spans that were actually used. + */ + + for (span in spans) { + this.deleteSpan(span) + } + } catch (e: Throwable) { + this.logger.error("Failed to process time tracking entries: ", e) + } finally { + this.tickWait.offer(Unit) + } + } + + private fun deleteSpan( + span: TimeTrackingReceivedSpan + ) { + val file = this.inboxDirectory.resolve("${span.id}.ttspan") + this.logger.debug("Deleting span {}", file) + Files.deleteIfExists(file) + } + + private fun writeEntry( + entry: TimeTrackingEntryOutgoing + ) { + val fileTmp = + this.outboxDirectory.resolve("${entry.timeEntry.id}.tteo.tmp") + val file = + this.outboxDirectory.resolve("${entry.timeEntry.id}.tteo") + + this.logger.debug("Writing entry {}", file) + Files.newOutputStream(fileTmp, WRITE, CREATE, TRUNCATE_EXISTING).use { s -> + entry.toProperties().store(s, "") + s.flush() + } + Files.move( + fileTmp, + file, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + } + + companion object { + + private data class MergeKey( + val accountID: AccountID, + val bookID: PlayerPalaceID, + val libraryID: URI, + val targetURI: URI + ) + + fun mergeEntries( + spans: List + ): List { + val spansByKey = + mutableMapOf>() + + for (span in spans) { + val key = MergeKey( + accountID = span.accountID, + bookID = span.bookID, + libraryID = span.libraryID, + targetURI = span.targetURI + ) + var existing = spansByKey.get(key) + if (existing == null) { + existing = mutableListOf() + } + existing.add(span) + spansByKey.put(key, existing) + } + + val results = mutableListOf() + for ((key, keySpans) in spansByKey) { + results.addAll(mergeSpansForKey(key, keySpans)) + } + return results.toList() + } + + private fun mergeSpansForKey( + key: MergeKey, + keySpans: MutableList + ): Collection { + keySpans.sortBy { e -> e.timeStarted } + + val secondsForMinute = + mutableMapOf() + + for (span in keySpans) { + val spanSeconds = + Duration.between(span.timeStarted, span.timeEnded) + .toMillis() / 1_000L + + val crossesMinuteBoundary = + span.timeStarted.minute != span.timeEnded.minute + + val minuteCurr = + span.timeStarted.withSecond(0) + val minuteNext = + minuteCurr.plusMinutes(1L) + + if (crossesMinuteBoundary) { + val addNext = span.timeEnded.second + val addCurr = 60 - span.timeStarted.second + secondsForMinute[minuteCurr] = + Math.min(60, (secondsForMinute[minuteCurr] ?: 0) + addCurr) + secondsForMinute[minuteNext] = + Math.min(60, (secondsForMinute[minuteNext] ?: 0) + addNext) + } else { + secondsForMinute[minuteCurr] = + Math.min(60, (secondsForMinute[minuteCurr] ?: 0) + spanSeconds) + } + } + + val results = mutableListOf() + for ((minute, seconds) in secondsForMinute) { + results.add( + TimeTrackingEntryOutgoing( + accountID = key.accountID, + bookID = key.bookID, + targetURI = key.targetURI, + libraryID = key.libraryID, + timeEntry = TimeTrackingEntry( + id = ULID.random(), + duringMinute = minute.toString(), + secondsPlayed = seconds.toInt() + ), + ) + ) + } + return results.toList() + } + + fun create( + clock: () -> OffsetDateTime, + frequency: Duration, + inputDirectory: Path, + outputDirectory: Path, + ): TimeTrackingMergeServiceType { + return TimeTrackingMerge( + clock = clock, + frequency = frequency, + inboxDirectory = inputDirectory, + outboxDirectory = outputDirectory + ) + } + } + + override fun awaitTick( + timeout: Long, + unit: TimeUnit + ) { + this.tickWait.poll(timeout, unit) + } + + override fun close() { + this.resources.close() + } +} diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingMergeServiceType.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingMergeServiceType.kt new file mode 100644 index 000000000..26aa747b8 --- /dev/null +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingMergeServiceType.kt @@ -0,0 +1,21 @@ +package org.nypl.simplified.books.time.tracking + +import java.util.concurrent.TimeUnit + +/** + * The merge service. + * + * This takes spans of time that were serialized by the [TimeTrackingCollectorServiceType] and + * merges them into time tracking entries to be sent to the server. Necessarily, it only operates + * on spans that are over a minute old, because there might still be spans to come in the current + * minute. + */ + +interface TimeTrackingMergeServiceType : AutoCloseable { + + /** + * A testing method; the caller will block until the next tick is completed. + */ + + fun awaitTick(timeout: Long, unit: TimeUnit) +} diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingReceivedSpan.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingReceivedSpan.kt new file mode 100644 index 000000000..ac405d1a0 --- /dev/null +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingReceivedSpan.kt @@ -0,0 +1,80 @@ +package org.nypl.simplified.books.time.tracking + +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID +import org.nypl.simplified.accounts.api.AccountID +import java.io.ByteArrayOutputStream +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.Properties +import java.util.UUID + +data class TimeTrackingReceivedSpan( + val id: UUID, + val accountID: AccountID, + val libraryID: URI, + val bookID: PlayerPalaceID, + val timeStarted: OffsetDateTime, + val timeEnded: OffsetDateTime, + val targetURI: URI +) { + + init { + require(this.timeStarted.offset == ZoneOffset.UTC) { + "Times must be in UTC" + } + require(this.timeEnded.offset == ZoneOffset.UTC) { + "Times must be in UTC" + } + } + + fun toBytes(): ByteArray { + return ByteArrayOutputStream().use { s -> + val p = this.toProperties() + p.store(s, "") + s.toByteArray() + } + } + + fun toProperties(): Properties { + val p = Properties() + p.setProperty("@Type", "TimeTrackingReceivedSpan") + p.setProperty("@Version", "1") + p.setProperty("ID", this.id.toString()) + p.setProperty("AccountID", this.accountID.uuid.toString()) + p.setProperty("BookID", this.bookID.value) + p.setProperty("LibraryID", this.libraryID.toString()) + p.setProperty("TargetURI", this.targetURI.toString()) + p.setProperty("TimeEnded", this.timeEnded.toString()) + p.setProperty("TimeStarted", this.timeStarted.toString()) + return p + } + + companion object { + fun ofFile( + file: Path + ): TimeTrackingReceivedSpan { + return Files.newInputStream(file).use { stream -> + val p = Properties() + p.load(stream) + ofProperties(p) + } + } + + fun ofProperties( + p: Properties + ): TimeTrackingReceivedSpan { + return TimeTrackingReceivedSpan( + id = UUID.fromString(p.getProperty("ID")), + accountID = AccountID(UUID.fromString(p.getProperty("AccountID"))), + libraryID = URI.create(p.getProperty("LibraryID")), + bookID = PlayerPalaceID(p.getProperty("BookID")), + timeStarted = OffsetDateTime.parse(p.getProperty("TimeStarted")), + timeEnded = OffsetDateTime.parse(p.getProperty("TimeEnded")), + targetURI = URI.create(p.getProperty("TargetURI")) + ) + } + } +} diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingInfo.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingRequest.kt similarity index 67% rename from simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingInfo.kt rename to simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingRequest.kt index e4673f9f4..8f4f1564b 100644 --- a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingInfo.kt +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingRequest.kt @@ -2,10 +2,9 @@ package org.nypl.simplified.books.time.tracking import java.net.URI -data class TimeTrackingInfo( - val accountId: String, +data class TimeTrackingRequest( val bookId: String, - val libraryId: String, + val libraryId: URI, val timeTrackingUri: URI, val timeEntries: List ) diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingResponse.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingResponse.kt deleted file mode 100644 index 5f49b3706..000000000 --- a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingResponse.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.nypl.simplified.books.time.tracking - -class TimeTrackingResponse( - val responses: List?, - val summary: TimeTrackingResponseSummary? -) diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingSender.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingSender.kt new file mode 100644 index 000000000..b01a4bbbf --- /dev/null +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingSender.kt @@ -0,0 +1,248 @@ +package org.nypl.simplified.books.time.tracking + +import com.io7m.jmulticlose.core.CloseableCollection +import org.nypl.simplified.profiles.controller.api.ProfilesControllerType +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import java.util.concurrent.Executors +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.stream.Collectors + +/** + * The sender service. + * + * The sender service attempts to send all time tracking entries that have been serialized into + * a directory by the [TimeTrackingCollector] service. It is responsible for merging entries into + * single requests in order to avoid overwhelming the remote side with lots of small requests, and + * is responsible for deleting serialized tracking entries when the remote side has accepted + * them. + */ + +class TimeTrackingSender private constructor( + private val profiles: ProfilesControllerType, + private val httpCalls: TimeTrackingHTTPCallsType, + private val debugDirectory: Path, + private val inputDirectory: Path, + private val frequency: Duration, +) : TimeTrackingSenderServiceType { + + private val logger = + LoggerFactory.getLogger(TimeTrackingSender::class.java) + + private val tickWrite = + LinkedBlockingQueue() + private val resources = + CloseableCollection.create() + + private val executor = + Executors.newSingleThreadScheduledExecutor { r -> + val thread = Thread(r) + thread.name = "org.nypl.simplified.books.time.tracking.sender[${thread.id}]" + thread.isDaemon = true + thread.priority = Thread.MIN_PRIORITY + thread + } + + init { + this.resources.add(AutoCloseable { + this.executor.shutdown() + this.executor.awaitTermination(30L, TimeUnit.SECONDS) + }) + this.executor.scheduleWithFixedDelay( + this::onTrySend, + 0L, + this.frequency.toMillis(), + TimeUnit.MILLISECONDS + ) + this.resources.add(AutoCloseable { + this.tickWrite.offer(Unit) + }) + } + + private fun isFileSuitable( + file: Path + ): Boolean { + if (!Files.isRegularFile(file)) { + return false + } + return file.toString().endsWith(".tteo") + } + + private fun onTrySend() { + try { + MDC.put("System", "TimeTracking") + MDC.put("SubSystem", "Sender") + MDC.put("TimeLoss", "false") + + val entryFiles: List = + Files.list(this.inputDirectory) + .filter { p -> this.isFileSuitable(p) } + .collect(Collectors.toList()) + + val entries = mutableListOf() + for (entryFile in entryFiles) { + try { + entries.add(TimeTrackingEntryOutgoing.ofFile(entryFile)) + } catch (e: Throwable) { + MDC.put("TimeLoss", "true") + this.logger.warn("Unable to parse local time tracking entry: ", e) + } + } + + val grouped = TimeTrackingEntryOutgoing.group(entries) + for ((key, outgoingEntries) in grouped) { + check(outgoingEntries.isNotEmpty()) { + "Outgoing entries cannot be empty" + } + this.sendOneBatch(key, outgoingEntries) + } + } catch (e: Throwable) { + this.logger.debug("Failed to send time tracking entries: ", e) + } + } + + private fun sendOneBatch( + key: TimeTrackingEntryOutgoing.Key, + outgoingEntries: List + ) { + try { + Files.createDirectories(this.debugDirectory) + Files.createDirectories(this.inputDirectory) + + outgoingEntries.forEach { e -> + TimeTrackingDebugging.onTimeTrackingSendAttempt( + timeTrackingDebugDirectory = this.debugDirectory.toFile(), + libraryId = key.libraryID.toString(), + bookId = key.bookID.value, + entryId = e.timeEntry.id, + seconds = e.timeEntry.secondsPlayed + ) + } + + val account = + this.profiles.profileCurrent().account(key.accountID) + + val response = + this.httpCalls.registerTimeTrackingInfo( + account = account, + request = TimeTrackingRequest( + bookId = key.bookID.value, + libraryId = key.libraryID, + timeTrackingUri = key.targetURI, + timeEntries = outgoingEntries.map { e -> e.timeEntry } + ) + ) + + /* + * If the responses list is empty, and the count suggests that all entries succeeded, we + * log success and delete the entry. Unfortunately, the spec leaves open the possibility + * that there will be a non-zero number of failures, and nothing in the responses list. In + * that edge case, we won't know _which_ entries failed and so we can do nothing other than + * send every entry again. The server is responsible for rejecting any entry that it has + * already received, so this should be safe. + */ + + if (response.responses.isEmpty()) { + if (response.summary.successes == outgoingEntries.size && response.summary.failures == 0) { + outgoingEntries.forEach { e -> this.entrySentSuccessfully(e) } + return + } + + check(response.summary.failures != 0) + outgoingEntries.forEach { e -> + TimeTrackingDebugging.onTimeTrackingSendAttemptFailed( + timeTrackingDebugDirectory = this.debugDirectory.toFile(), + libraryId = key.libraryID.toString(), + bookId = key.bookID.value, + entryId = e.timeEntry.id + ) + } + return + } + + /* + * For each successful response, we need to log the successful send attempt and then delete + * the entry so that it isn't sent again. For each failed attempt, we simply log the failure. + */ + + for (r in response.responses) { + val e = outgoingEntries.firstOrNull { entry -> entry.timeEntry.id == r.id } + if (r.isStatusSuccess() || r.isStatusGone()) { + if (e != null) { + this.entrySentSuccessfully(e) + } + } else { + if (e != null) { + TimeTrackingDebugging.onTimeTrackingSendAttemptFailed( + timeTrackingDebugDirectory = this.debugDirectory.toFile(), + libraryId = key.libraryID.toString(), + bookId = key.bookID.value, + entryId = e.timeEntry.id + ) + } + } + } + } catch (e: Throwable) { + this.logger.debug("Failed to send time tracking entries: ", e) + outgoingEntries.forEach { entry -> + TimeTrackingDebugging.onTimeTrackingSendAttemptFailedExceptionally( + timeTrackingDebugDirectory = this.debugDirectory.toFile(), + libraryId = key.libraryID.toString(), + bookId = key.bookID.value, + entryId = entry.timeEntry.id, + exception = e + ) + } + } finally { + this.tickWrite.offer(Unit) + } + } + + private fun entrySentSuccessfully( + entry: TimeTrackingEntryOutgoing + ) { + TimeTrackingDebugging.onTimeTrackingSendAttemptSucceeded( + timeTrackingDebugDirectory = this.debugDirectory.toFile(), + libraryId = entry.libraryID.toString(), + bookId = entry.bookID.value, + entryId = entry.timeEntry.id + ) + + Files.deleteIfExists( + this.inputDirectory.resolve("${entry.timeEntry.id}.tteo") + ) + } + + companion object { + fun create( + profiles: ProfilesControllerType, + httpCalls: TimeTrackingHTTPCallsType, + debugDirectory: Path, + inputDirectory: Path, + frequency: Duration, + ): TimeTrackingSenderServiceType { + return TimeTrackingSender( + profiles = profiles, + httpCalls = httpCalls, + debugDirectory = debugDirectory, + inputDirectory = inputDirectory, + frequency = frequency + ) + } + } + + override fun awaitWrite( + timeout: Long, + unit: TimeUnit + ) { + this.tickWrite.poll(timeout, unit) + } + + override fun close() { + this.resources.close() + } +} diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingSenderServiceType.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingSenderServiceType.kt new file mode 100644 index 000000000..8d8e340a8 --- /dev/null +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingSenderServiceType.kt @@ -0,0 +1,20 @@ +package org.nypl.simplified.books.time.tracking + +import java.util.concurrent.TimeUnit + +/** + * The sender service. + * + * This takes time tracking entries that were merged by the [TimeTrackingMergeServiceType] service + * and sends them to the server. It then deletes entries that were successfully sent, and retries + * entries that were not. + */ + +interface TimeTrackingSenderServiceType : AutoCloseable { + + /** + * A testing method; the caller will block until the next write is completed. + */ + + fun awaitWrite(timeout: Long, unit: TimeUnit) +} diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingServerResponse.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingServerResponse.kt new file mode 100644 index 000000000..0488bf008 --- /dev/null +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingServerResponse.kt @@ -0,0 +1,6 @@ +package org.nypl.simplified.books.time.tracking + +data class TimeTrackingServerResponse( + val responses: List, + val summary: TimeTrackingServerResponseSummary +) diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingResponseEntry.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingServerResponseEntry.kt similarity index 90% rename from simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingResponseEntry.kt rename to simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingServerResponseEntry.kt index 788d02f2c..1b3ba04e1 100644 --- a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingResponseEntry.kt +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingServerResponseEntry.kt @@ -1,6 +1,6 @@ package org.nypl.simplified.books.time.tracking -class TimeTrackingResponseEntry( +data class TimeTrackingServerResponseEntry( val id: String, val message: String, val status: Int diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingResponseSummary.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingServerResponseSummary.kt similarity index 70% rename from simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingResponseSummary.kt rename to simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingServerResponseSummary.kt index 8b846b4df..6cada45d3 100644 --- a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingResponseSummary.kt +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingServerResponseSummary.kt @@ -1,6 +1,6 @@ package org.nypl.simplified.books.time.tracking -class TimeTrackingResponseSummary( +data class TimeTrackingServerResponseSummary( val failures: Int, val successes: Int, val total: Int diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingService.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingService.kt index 632ccce16..dc3f1e2f6 100644 --- a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingService.kt +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingService.kt @@ -1,409 +1,97 @@ package org.nypl.simplified.books.time.tracking -import android.content.Context -import io.azam.ulidj.ULID +import com.io7m.jattribute.core.AttributeType +import com.io7m.jattribute.core.Attributes +import com.io7m.jmulticlose.core.CloseableCollection import io.reactivex.Observable -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers -import org.joda.time.DateTime -import org.joda.time.format.DateTimeFormat -import org.librarysimplified.audiobook.api.PlayerEvent +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID +import org.librarysimplified.audiobook.time_tracking.PlayerTimeTracked import org.nypl.simplified.accounts.api.AccountID import org.nypl.simplified.profiles.controller.api.ProfilesControllerType import org.slf4j.LoggerFactory -import java.io.File import java.net.URI -import java.util.UUID -import java.util.concurrent.TimeUnit -import kotlin.math.min - -class TimeTrackingService( - context: Context, - private val httpCalls: TimeTrackingHTTPCallsType, - private val profilesController: ProfilesControllerType, - private val timeTrackingDirectory: File +import java.nio.file.Path +import java.time.Duration +import java.time.OffsetDateTime + +class TimeTrackingService private constructor( + private val status: AttributeType, + private val collector: TimeTrackingCollectorServiceType, + private val merge: TimeTrackingMergeServiceType, + private val sender: TimeTrackingSenderServiceType ) : TimeTrackingServiceType { - companion object { - private const val FILE_NAME_TIME_ENTRIES = "time_entries.json" - private const val FILE_NAME_TIME_ENTRIES_RETRY = "time_entries_to_retry.json" - - private const val MAX_SECONDS_PLAYED = 60 - } - - private val logger = LoggerFactory.getLogger(TimeTrackingServiceType::class.java) - - private val dateFormatter = - DateTimeFormat.forPattern("yyyy-MM-dd'T'HH:mm'Z'") - - private val disposables = CompositeDisposable() - - private lateinit var timeEntriesFile: File - private lateinit var timeEntriesToRetryFile: File - - private var audiobookPlayingDisposable: Disposable? = null - private val connectivityListener: TimeTrackingConnectivityListener - private var currentTimeTrackingEntry: TimeTrackingEntry? = null - - private var firstIterationOfService = true - private var isPlaying = false - private var isOnAudiobookScreen = false - private var shouldSaveRemotely = false - private var tracking = false + private val resources = + CloseableCollection.create() init { - connectivityListener = TimeTrackingConnectivityListener( - context = context, - onConnectivityStateRetrieved = { hasInternet -> - handleConnectivityState(hasInternet) - } - ) + this.resources.add(this.collector) + this.resources.add(this.merge) + this.resources.add(this.sender) } - override fun startTimeTracking( - accountID: AccountID, - bookId: String, - libraryId: String, - timeTrackingUri: URI? - ) { - this.tracking = timeTrackingUri != null - if (timeTrackingUri == null) { - logger.debug( - "Account {} and book {} has no time tracking uri", - accountID, - bookId - ) - return - } - - logger.debug( - "Start tracking time for account {} and book {}", - accountID.uuid.toString(), - bookId - ) - val libraryFile = File(timeTrackingDirectory, accountID.uuid.toString()) - - // create a directory for the library - libraryFile.mkdirs() - - val bookFile = File(libraryFile, bookId) - - // create a directory for the book inside the library - bookFile.mkdirs() - - timeEntriesFile = File(bookFile, FILE_NAME_TIME_ENTRIES) - timeEntriesToRetryFile = File(bookFile, FILE_NAME_TIME_ENTRIES_RETRY) - - if (!timeEntriesFile.exists()) { - // create an entries file for this book with an initial time tracking info - timeEntriesFile.createNewFile() - - TimeTrackingInfoFileUtils.saveTimeTrackingInfoOnFile( - timeTrackingInfo = TimeTrackingInfo( - accountId = accountID.uuid.toString(), - bookId = bookId, - libraryId = libraryId, - timeEntries = listOf(), - timeTrackingUri = timeTrackingUri + companion object { + private val logger = + LoggerFactory.getLogger(TimeTrackingService::class.java) + + fun create( + profiles: ProfilesControllerType, + httpCalls: TimeTrackingHTTPCallsType, + clock: () -> OffsetDateTime, + timeSegments: Observable, + debugDirectory: Path, + collectorDirectory: Path, + senderDirectory: Path, + ): TimeTrackingServiceType { + val status: AttributeType = + Attributes.create { e -> this.logger.debug("Attribute exception: ", e) } + .withValue(TimeTrackingStatus.Inactive) + + return TimeTrackingService( + status = status, + collector = TimeTrackingCollector.create( + profiles = profiles, + status = status, + timeSegments = timeSegments, + debugDirectory = debugDirectory, + outputDirectory = collectorDirectory ), - file = timeEntriesFile - ) - } - - if (!timeEntriesToRetryFile.exists()) { - // create a file for possible entries that weren't successfully saved on the server - timeEntriesToRetryFile.createNewFile() - - TimeTrackingInfoFileUtils.saveTimeTrackingInfoOnFile( - timeTrackingInfo = TimeTrackingInfo( - accountId = accountID.uuid.toString(), - bookId = bookId, - libraryId = libraryId, - timeEntries = listOf(), - timeTrackingUri = timeTrackingUri + merge = TimeTrackingMerge.create( + clock = clock, + frequency = Duration.ofSeconds(30L), + inputDirectory = collectorDirectory, + outputDirectory = senderDirectory ), - file = timeEntriesFile + sender = TimeTrackingSender.create( + profiles = profiles, + httpCalls = httpCalls, + debugDirectory = debugDirectory, + inputDirectory = senderDirectory, + frequency = Duration.ofSeconds(30L) + ) ) } - - isOnAudiobookScreen = true - } - - override fun onPlayerEventReceived(playerEvent: PlayerEvent) { - if (!this.tracking) { - return - } - - when (playerEvent) { - is PlayerEvent.PlayerEventWithPosition.PlayerEventPlaybackProgressUpdate, - is PlayerEvent.PlayerEventWithPosition.PlayerEventPlaybackStarted -> { - isPlaying = true - if (audiobookPlayingDisposable == null) { - createTimeTrackingEntry() - startEventDisposables() - } - } - - is PlayerEvent.PlayerEventWithPosition.PlayerEventPlaybackBuffering, - is PlayerEvent.PlayerEventWithPosition.PlayerEventPlaybackWaitingForAction, - is PlayerEvent.PlayerEventWithPosition.PlayerEventChapterWaiting, - is PlayerEvent.PlayerEventWithPosition.PlayerEventPlaybackPaused, - is PlayerEvent.PlayerEventWithPosition.PlayerEventPlaybackStopped, - is PlayerEvent.PlayerEventWithPosition.PlayerEventChapterCompleted -> { - isPlaying = false - } - is PlayerEvent.PlayerEventWithPosition.PlayerEventCreateBookmark, - is PlayerEvent.PlayerEventPlaybackRateChanged, - is PlayerEvent.PlayerEventError, - PlayerEvent.PlayerEventManifestUpdated -> { - // do nothing - } - - is PlayerEvent.PlayerAccessibilityEvent.PlayerAccessibilityChapterSelected, - is PlayerEvent.PlayerAccessibilityEvent.PlayerAccessibilityErrorOccurred, - is PlayerEvent.PlayerAccessibilityEvent.PlayerAccessibilityIsBuffering, - is PlayerEvent.PlayerAccessibilityEvent.PlayerAccessibilityIsWaitingForChapter, - is PlayerEvent.PlayerAccessibilityEvent.PlayerAccessibilityPlaybackRateChanged, - is PlayerEvent.PlayerAccessibilityEvent.PlayerAccessibilitySleepTimerSettingChanged, - is PlayerEvent.PlayerEventDeleteBookmark, - is PlayerEvent.PlayerEventWithPosition.PlayerEventPlaybackPreparing -> { - // do nothing - } - } } - override fun stopTracking() { - if (!this.tracking) { - return - } - - logger.debug("Stop tracking playing time") - - disposables.clear() - - // set this to null so it can work the next time - audiobookPlayingDisposable = null - - saveTimeTrackingInfoLocally() - currentTimeTrackingEntry = null - firstIterationOfService = true - shouldSaveRemotely = false - isOnAudiobookScreen = false - } - - private fun createTimeTrackingEntry() { - currentTimeTrackingEntry = TimeTrackingEntry( - id = ULID.random(), - duringMinute = dateFormatter.print(DateTime.now()), - secondsPlayed = 0 - ) - } - - private fun getTimeTrackingInfoLocallyStored(): TimeTrackingInfo? { - return TimeTrackingJSON.convertBytesToTimeTrackingInfo( - bytes = timeEntriesFile.readBytes() - ) - } - - private fun handleConnectivityState(hasInternet: Boolean) { - // if the user is on an audiobook player screen, it means the entries will most likely be sent - // to the server, so there's no need to do anything else - if (isOnAudiobookScreen) { - return - } - - if (hasInternet) { - saveAllLocalTimeTrackingInfoRemotely() - } - } - - private fun saveAllLocalTimeTrackingInfoRemotely() { - val libraries = timeTrackingDirectory.listFiles().orEmpty() - - libraries.forEach { library -> - val books = library.listFiles().orEmpty() - - if (books.isEmpty()) { - library.deleteRecursively() - return@forEach - } - - books.forEach { book -> - val bookFiles = book.listFiles().orEmpty() - - if (bookFiles.isNotEmpty()) { - bookFiles.forEach { file -> - val fileTimeTrackingInfo = TimeTrackingInfoFileUtils.getTimeTrackingInfoFromFile( - file = file - ) - - val timeTrackingInfo = fileTimeTrackingInfo?.copy( - timeEntries = fileTimeTrackingInfo.timeEntries.filter { timeEntry -> - timeEntry.isValidTimeEntry() - } - ) - - if (!timeTrackingInfo?.timeEntries.isNullOrEmpty()) { - val updatedTimeTrackingInfo = saveTimeTrackingInfoRemotely( - timeTrackingInfo = timeTrackingInfo!! - ) - - // we need to update the file's time tracking info with the updated info obtained from - // the server's response - TimeTrackingInfoFileUtils.saveTimeTrackingInfoOnFile( - timeTrackingInfo = updatedTimeTrackingInfo, - file = file - ) - } else { - file.delete() - } - } - } else { - book.deleteRecursively() - } - } - } - } - - private fun saveTimeTrackingInfoLocally() { - val timeTrackingInfo = currentTimeTrackingEntry?.copy( - secondsPlayed = min(currentTimeTrackingEntry!!.secondsPlayed, MAX_SECONDS_PLAYED) - ) - - if (timeTrackingInfo != null) { - val currentBookInfo = getTimeTrackingInfoLocallyStored() - - if (currentBookInfo != null) { - val currentEntries = currentBookInfo.timeEntries - val updatedTimeTrackingInfo = currentBookInfo.copy( - timeEntries = if (firstIterationOfService) { - // if it's the first iteration of this saving method, we can add the current time tracking - // info - ArrayList(currentEntries).apply { - add(timeTrackingInfo) - } - } else if (shouldSaveRemotely) { - // if the info should be remotely saved, we update the last entry one last time and add a - // new entry for future iterations - ArrayList(currentEntries).apply { - set(currentEntries.lastIndex, timeTrackingInfo) - createTimeTrackingEntry() - add(currentTimeTrackingEntry) - } - } else if (currentEntries.isNotEmpty()) { - // if there's no need to save the info remotely, we just update the last index's info - ArrayList(currentEntries).apply { - set(currentEntries.lastIndex, timeTrackingInfo) - } - } else { - // there are no current entries, so we can create a new list - listOf(timeTrackingInfo) - } - ) - - firstIterationOfService = false - - TimeTrackingInfoFileUtils.saveTimeTrackingInfoOnFile( - timeTrackingInfo = updatedTimeTrackingInfo, - file = timeEntriesFile - ) - } - } + override fun onBookOpenedForTracking( + accountID: AccountID, + bookId: PlayerPalaceID, + libraryId: String, + timeTrackingUri: URI + ) { + this.status.set(TimeTrackingStatus.Active( + accountID = accountID, + bookId = bookId, + libraryId = libraryId, + timeTrackingUri = timeTrackingUri + )) } - private fun saveTimeTrackingInfoRemotely(timeTrackingInfo: TimeTrackingInfo): TimeTrackingInfo { - val failedEntries = try { - httpCalls.registerTimeTrackingInfo( - timeTrackingInfo = timeTrackingInfo.copy( - timeEntries = timeTrackingInfo.timeEntries.filter { timeEntry -> - timeEntry.isValidTimeEntry() - } - ), - account = profilesController - .profileCurrent() - .account(AccountID(UUID.fromString(timeTrackingInfo.accountId))) - ) - } catch (exception: Exception) { - logger.error("Error while saving time tracking info remotely: ", exception) - - // in case an exception occurs, we keep the original time entries - timeTrackingInfo.timeEntries - } - - return timeTrackingInfo.copy( - timeEntries = failedEntries - ) + override fun onBookClosed() { + this.status.set(TimeTrackingStatus.Inactive) } - private fun startEventDisposables() { - // this timer will be running every second and will update the time tracking entry - // 'secondsPlayed' value accordingly - audiobookPlayingDisposable = - Observable.interval(1L, TimeUnit.SECONDS) - .subscribeOn(Schedulers.io()) - .subscribe( - { - if (isPlaying) { - currentTimeTrackingEntry = currentTimeTrackingEntry?.copy( - secondsPlayed = currentTimeTrackingEntry!!.secondsPlayed + 1 - ) - } - - saveTimeTrackingInfoLocally() - - if (shouldSaveRemotely) { - shouldSaveRemotely = false - - val localTimeTrackingInfo = getTimeTrackingInfoLocallyStored() - val timeTrackingInfo = localTimeTrackingInfo?.copy( - timeEntries = localTimeTrackingInfo.timeEntries.filter { timeEntry -> - timeEntry.isValidTimeEntry() - } - ) - - if (!timeTrackingInfo?.timeEntries.isNullOrEmpty()) { - val updatedTimeTrackingInfo = saveTimeTrackingInfoRemotely( - timeTrackingInfo = timeTrackingInfo!! - ) - - // we can 'reset' the current time entries file - TimeTrackingInfoFileUtils.saveTimeTrackingInfoOnFile( - timeTrackingInfo = timeTrackingInfo.copy( - timeEntries = listOf() - ), - file = timeEntriesFile - ) - - // we need to add the failed entries to the 'retry' file - TimeTrackingInfoFileUtils.addEntriesToFile( - entries = updatedTimeTrackingInfo.timeEntries, - file = timeEntriesToRetryFile - ) - } - } - }, - { - logger.error("Error on audiobook playing timer") - it.printStackTrace() - } - ) - - disposables.add(audiobookPlayingDisposable!!) - - disposables.add( - // this timer will be responsible for updating the flag to save the entries on the server - Observable.interval(1L, TimeUnit.MINUTES) - .subscribeOn(Schedulers.io()) - .subscribe( - { - shouldSaveRemotely = true - }, - { - logger.error("Error on remote storage timer") - it.printStackTrace() - } - ) - ) + override fun close() { + this.resources.close() } } diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingServiceType.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingServiceType.kt index 2fa97074f..515c4ed3a 100644 --- a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingServiceType.kt +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingServiceType.kt @@ -1,23 +1,17 @@ package org.nypl.simplified.books.time.tracking -import org.librarysimplified.audiobook.api.PlayerEvent +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID import org.nypl.simplified.accounts.api.AccountID import java.net.URI -/** - * The time tracking service interface. - */ +interface TimeTrackingServiceType : AutoCloseable { -interface TimeTrackingServiceType { - - fun startTimeTracking( + fun onBookOpenedForTracking( accountID: AccountID, - bookId: String, + bookId: PlayerPalaceID, libraryId: String, - timeTrackingUri: URI? + timeTrackingUri: URI ) - fun onPlayerEventReceived(playerEvent: PlayerEvent) - - fun stopTracking() + fun onBookClosed() } diff --git a/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingStatus.kt b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingStatus.kt new file mode 100644 index 000000000..115b7362e --- /dev/null +++ b/simplified-books-time-tracking/src/main/java/org/nypl/simplified/books/time/tracking/TimeTrackingStatus.kt @@ -0,0 +1,16 @@ +package org.nypl.simplified.books.time.tracking + +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID +import org.nypl.simplified.accounts.api.AccountID +import java.net.URI + +sealed class TimeTrackingStatus { + data class Active( + val accountID: AccountID, + val bookId: PlayerPalaceID, + val libraryId: String, + val timeTrackingUri: URI + ) : TimeTrackingStatus() + + data object Inactive : TimeTrackingStatus() +} diff --git a/simplified-deeplinks-controller-api/README.md b/simplified-deeplinks-controller-api/README.md deleted file mode 100644 index 385a59185..000000000 --- a/simplified-deeplinks-controller-api/README.md +++ /dev/null @@ -1,5 +0,0 @@ -org.librarysimplified.deeplinks.controller.api -=== - -The `org.librarysimplified.deeplinks.controller.api` module specifies -the _controller_ API for _deep links_ functionality. diff --git a/simplified-deeplinks-controller-api/build.gradle.kts b/simplified-deeplinks-controller-api/build.gradle.kts deleted file mode 100644 index 543a4e02c..000000000 --- a/simplified-deeplinks-controller-api/build.gradle.kts +++ /dev/null @@ -1,10 +0,0 @@ -dependencies { - implementation(project(":simplified-accounts-api")) - - implementation(libs.kotlin.reflect) - implementation(libs.kotlin.stdlib) - implementation(libs.rxjava2) - - compileOnly(libs.google.auto.value) - annotationProcessor(libs.google.auto.value.processor) -} diff --git a/simplified-deeplinks-controller-api/gradle.properties b/simplified-deeplinks-controller-api/gradle.properties deleted file mode 100644 index cd4874d52..000000000 --- a/simplified-deeplinks-controller-api/gradle.properties +++ /dev/null @@ -1,4 +0,0 @@ -POM_ARTIFACT_ID=org.librarysimplified.deeplinks.controller.api -POM_DESCRIPTION=Library Simplified (Deep Links controller API) -POM_NAME=org.librarysimplified.deeplinks.controller.api -POM_PACKAGING=aar diff --git a/simplified-deeplinks-controller-api/src/main/AndroidManifest.xml b/simplified-deeplinks-controller-api/src/main/AndroidManifest.xml deleted file mode 100644 index 8072ee00d..000000000 --- a/simplified-deeplinks-controller-api/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/simplified-deeplinks-controller-api/src/main/java/org/nypl/simplified/deeplinks/controller/api/DeepLinkEvent.kt b/simplified-deeplinks-controller-api/src/main/java/org/nypl/simplified/deeplinks/controller/api/DeepLinkEvent.kt deleted file mode 100644 index 7c23e12da..000000000 --- a/simplified-deeplinks-controller-api/src/main/java/org/nypl/simplified/deeplinks/controller/api/DeepLinkEvent.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.nypl.simplified.deeplinks.controller.api - -import org.nypl.simplified.accounts.api.AccountID - -/** - * The type of deep link events. - */ -sealed class DeepLinkEvent { - - /** - * The account ID for the library to be navigated to. - */ - - abstract val accountID: AccountID? - - /** - * The screen ID for the screen to be navigated to. - */ - - abstract val screenID: ScreenID - - /** - * The barcode to populate on the library login screen. Does nothing unless screenID is ScreenID.LOGIN. (Optional) - */ - - abstract val barcode: String? - - /** - * A new deep link was intercepted - */ - - data class DeepLinkIntercepted( - override val accountID: AccountID, - override val screenID: ScreenID, - override val barcode: String? - ) : DeepLinkEvent() -} diff --git a/simplified-deeplinks-controller-api/src/main/java/org/nypl/simplified/deeplinks/controller/api/DeepLinksControllerType.kt b/simplified-deeplinks-controller-api/src/main/java/org/nypl/simplified/deeplinks/controller/api/DeepLinksControllerType.kt deleted file mode 100644 index 59c271c29..000000000 --- a/simplified-deeplinks-controller-api/src/main/java/org/nypl/simplified/deeplinks/controller/api/DeepLinksControllerType.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.nypl.simplified.deeplinks.controller.api - -import io.reactivex.Observable -import org.nypl.simplified.accounts.api.AccountID - -interface DeepLinksControllerType { - - fun deepLinkEvents(): Observable - - fun publishDeepLinkEvent(accountID: AccountID, screenID: ScreenID, barcode: String?) -} diff --git a/simplified-deeplinks-controller-api/src/main/java/org/nypl/simplified/deeplinks/controller/api/ScreenID.kt b/simplified-deeplinks-controller-api/src/main/java/org/nypl/simplified/deeplinks/controller/api/ScreenID.kt deleted file mode 100644 index 64b09a150..000000000 --- a/simplified-deeplinks-controller-api/src/main/java/org/nypl/simplified/deeplinks/controller/api/ScreenID.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.nypl.simplified.deeplinks.controller.api - -enum class ScreenID { - LOGIN, UNSPECIFIED -} diff --git a/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedLoader.kt b/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedLoader.kt index 25bc60c20..5528e3ee8 100644 --- a/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedLoader.kt +++ b/simplified-feeds-api/src/main/java/org/nypl/simplified/feeds/api/FeedLoader.kt @@ -122,7 +122,7 @@ class FeedLoader private constructor( return FeedLoaderSuccess(feed, opdsFeedResponse.second) } catch (e: FeedHTTPTransportException) { - this.log.error("feed transport exception: ", e) + this.log.debug("feed transport exception: ", e) if (e.code == 401) { return FeedLoaderFailure.FeedLoaderFailedAuthentication( @@ -139,7 +139,7 @@ class FeedLoader private constructor( message = e.localizedMessage ?: "" ) } catch (e: Exception) { - this.log.error("feed exception: ", e) + this.log.debug("feed exception: ", e) return FeedLoaderFailure.FeedLoaderFailedGeneral( problemReport = null, diff --git a/simplified-links-json/src/main/java/org.nypl.simplified.links.json/LinkParsing.kt b/simplified-links-json/src/main/java/org.nypl.simplified.links.json/LinkParsing.kt index c59625bd2..159536079 100644 --- a/simplified-links-json/src/main/java/org.nypl.simplified.links.json/LinkParsing.kt +++ b/simplified-links-json/src/main/java/org.nypl.simplified.links.json/LinkParsing.kt @@ -77,7 +77,7 @@ object LinkParsing { } ) } catch (e: JSONParseException) { - this.logger.error("error parsing link object: ", e) + this.logger.debug("error parsing link object: ", e) ParseResult.Failure( warnings = listOf(), errors = listOf( @@ -89,7 +89,7 @@ object LinkParsing { ) ) } catch (e: Exception) { - this.logger.error("error parsing link object: ", e) + this.logger.debug("error parsing link object: ", e) ParseResult.Failure( warnings = listOf(), errors = listOf( diff --git a/simplified-main/build.gradle.kts b/simplified-main/build.gradle.kts index 6e8406963..1a328b166 100644 --- a/simplified-main/build.gradle.kts +++ b/simplified-main/build.gradle.kts @@ -50,7 +50,6 @@ dependencies { implementation(project(":simplified-buildconfig-api")) implementation(project(":simplified-content-api")) implementation(project(":simplified-crashlytics-api")) - implementation(project(":simplified-deeplinks-controller-api")) implementation(project(":simplified-documents")) implementation(project(":simplified-feeds-api")) implementation(project(":simplified-files")) @@ -128,7 +127,6 @@ dependencies { implementation(libs.google.material) implementation(libs.io7m.jnull) implementation(libs.irradia.mime.api) - implementation(libs.irradia.mime.api) implementation(libs.irradia.opds2.api) implementation(libs.irradia.opds2.lexical) implementation(libs.irradia.opds2.librarysimplified) @@ -139,12 +137,11 @@ dependencies { implementation(libs.jackson.core) implementation(libs.jackson.databind) implementation(libs.joda.time) - implementation(libs.joda.time) implementation(libs.kotlin.reflect) implementation(libs.kotlin.stdlib) - implementation(libs.kotlin.stdlib) implementation(libs.logback.android) implementation(libs.palace.audiobook.feedbooks) + implementation(libs.palace.audiobook.time.tracking) implementation(libs.palace.audiobook.views) implementation(libs.palace.drm.core) implementation(libs.palace.http.api) @@ -161,6 +158,5 @@ dependencies { implementation(libs.rxjava2) implementation(libs.rxjava2.extensions) implementation(libs.slf4j) - implementation(libs.slf4j) implementation(libs.transifex.sdk) } diff --git a/simplified-main/src/main/java/org/librarysimplified/main/MainActivity.kt b/simplified-main/src/main/java/org/librarysimplified/main/MainActivity.kt index fbdbc56b3..1ed505eae 100644 --- a/simplified-main/src/main/java/org/librarysimplified/main/MainActivity.kt +++ b/simplified-main/src/main/java/org/librarysimplified/main/MainActivity.kt @@ -14,8 +14,6 @@ import androidx.core.app.ActivityCompat import androidx.fragment.app.FragmentManager import androidx.fragment.app.commit import androidx.lifecycle.ViewModelProvider -import com.google.firebase.dynamiclinks.FirebaseDynamicLinks -import com.google.firebase.dynamiclinks.PendingDynamicLinkData import org.librarysimplified.services.api.Services import org.librarysimplified.ui.onboarding.OnboardingEvent import org.librarysimplified.ui.onboarding.OnboardingFragment @@ -26,8 +24,6 @@ import org.librarysimplified.ui.tutorial.TutorialFragment import org.nypl.simplified.accounts.api.AccountID import org.nypl.simplified.accounts.registry.api.AccountProviderRegistryType import org.nypl.simplified.buildconfig.api.BuildConfigurationServiceType -import org.nypl.simplified.deeplinks.controller.api.DeepLinksControllerType -import org.nypl.simplified.deeplinks.controller.api.ScreenID import org.nypl.simplified.listeners.api.ListenerRepository import org.nypl.simplified.listeners.api.listenerRepositories import org.nypl.simplified.oauth.OAuthCallbackIntentParsing @@ -36,14 +32,11 @@ import org.nypl.simplified.profiles.api.ProfilesDatabaseType.AnonymousProfileEna import org.nypl.simplified.profiles.api.ProfilesDatabaseType.AnonymousProfileEnabled.ANONYMOUS_PROFILE_ENABLED import org.nypl.simplified.profiles.controller.api.ProfileAccountLoginRequest.OAuthWithIntermediaryComplete import org.nypl.simplified.profiles.controller.api.ProfilesControllerType -import org.nypl.simplified.taskrecorder.api.TaskResult import org.nypl.simplified.ui.branding.BrandingSplashServiceType import org.slf4j.LoggerFactory import org.thepalaceproject.ui.UIMainActivity import org.thepalaceproject.ui.UIMigration -import java.net.URI import java.util.ServiceLoader -import java.util.concurrent.TimeUnit class MainActivity : AppCompatActivity(R.layout.main_host) { @@ -80,7 +73,6 @@ class MainActivity : AppCompatActivity(R.layout.main_host) { return } - interceptDeepLink() val toolbar = this.findViewById(R.id.mainToolbar) as Toolbar this.setSupportActionBar(toolbar) this.supportActionBar?.setDisplayHomeAsUpEnabled(true) @@ -100,80 +92,6 @@ class MainActivity : AppCompatActivity(R.layout.main_host) { askForNotificationsPermission() } - private fun interceptDeepLink() { - val pendingLink = - FirebaseDynamicLinks.getInstance() - .getDynamicLink(intent) - - pendingLink.addOnFailureListener(this) { e -> - this.logger.error("Failed to retrieve dynamic link: ", e) - } - - pendingLink.addOnSuccessListener { linkData: PendingDynamicLinkData? -> - val deepLink = linkData?.link - if (deepLink == null) { - this.logger.error("Pending deep link had no link field") - return@addOnSuccessListener - } - - val libraryID = deepLink.getQueryParameter("libraryid") - if (libraryID == null) { - this.logger.error("Pending deep link had no libraryid parameter.") - return@addOnSuccessListener - } - - val barcode = deepLink.getQueryParameter("barcode") - if (barcode == null) { - this.logger.error("Pending deep link had no barcode parameter.") - return@addOnSuccessListener - } - - val services = - Services.serviceDirectory() - val profiles = - services.requireService(ProfilesControllerType::class.java) - val deepLinksController = - services.requireService(DeepLinksControllerType::class.java) - - val accountURI = - URI("urn:uuid" + libraryID) - - val accountResult = - profiles.profileAccountCreate(accountURI) - .get(3L, TimeUnit.MINUTES) - - // XXX: Creating an error report would be nice here. - if (accountResult is TaskResult.Failure) { - this.logger.error("Unable to create an account with ID {}: ", accountURI) - return@addOnSuccessListener - } - - val accountID = - (accountResult as TaskResult.Success).result.id - val screenRaw = - deepLink.getQueryParameter("screen") - - val screenId: ScreenID = - when (screenRaw) { - null -> { - this.logger.warn("Deep link did not have a screen parameter.") - ScreenID.UNSPECIFIED - } - LOGIN_SCREEN_ID -> ScreenID.LOGIN - else -> { - this.logger.warn("Deep link had an unrecognized screen parameter {}.", screenRaw) - ScreenID.UNSPECIFIED - } - } - - deepLinksController.publishDeepLinkEvent( - accountID = accountID, - screenID = screenId, - barcode = barcode - ) - } - } - private fun askForNotificationsPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ActivityCompat.requestPermissions( @@ -250,7 +168,6 @@ class MainActivity : AppCompatActivity(R.layout.main_host) { override fun onStart() { super.onStart() this.listenerRepo.registerHandler(this::handleEvent) - interceptDeepLink() } override fun onStop() { diff --git a/simplified-main/src/main/java/org/librarysimplified/main/MainApplication.kt b/simplified-main/src/main/java/org/librarysimplified/main/MainApplication.kt index 26c88edb5..0af90f0c8 100644 --- a/simplified-main/src/main/java/org/librarysimplified/main/MainApplication.kt +++ b/simplified-main/src/main/java/org/librarysimplified/main/MainApplication.kt @@ -68,7 +68,7 @@ class MainApplication : Application() { val info = this.packageManager.getPackageInfo(this.packageName, 0) info.versionCode.toString() } catch (e: Exception) { - this.logger.error("version info unavailable: ", e) + this.logger.debug("version info unavailable: ", e) "UNKNOWN" } } diff --git a/simplified-main/src/main/java/org/librarysimplified/main/MainBookFormatSupport.kt b/simplified-main/src/main/java/org/librarysimplified/main/MainBookFormatSupport.kt index 67812e68f..c9060a411 100644 --- a/simplified-main/src/main/java/org/librarysimplified/main/MainBookFormatSupport.kt +++ b/simplified-main/src/main/java/org/librarysimplified/main/MainBookFormatSupport.kt @@ -63,7 +63,7 @@ object MainBookFormatSupport { } return false } catch (e: Exception) { - this.logger.error("one or more viewer providers raised an exception: ", e) + this.logger.debug("one or more viewer providers raised an exception: ", e) return false } } diff --git a/simplified-main/src/main/java/org/librarysimplified/main/MainFragment.kt b/simplified-main/src/main/java/org/librarysimplified/main/MainFragment.kt index 3c04182d7..0a575ca9f 100644 --- a/simplified-main/src/main/java/org/librarysimplified/main/MainFragment.kt +++ b/simplified-main/src/main/java/org/librarysimplified/main/MainFragment.kt @@ -1,5 +1,6 @@ package org.librarysimplified.main +import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem @@ -13,10 +14,13 @@ import androidx.lifecycle.ViewModelProvider import com.google.android.material.bottomnavigation.BottomNavigationItemView import com.google.android.material.bottomnavigation.BottomNavigationView import io.reactivex.disposables.CompositeDisposable +import org.librarysimplified.audiobook.views.PlayerModel +import org.librarysimplified.audiobook.views.PlayerModelState import org.librarysimplified.services.api.Services import org.librarysimplified.ui.catalog.saml20.CatalogSAML20Fragment import org.librarysimplified.ui.catalog.saml20.CatalogSAML20FragmentParameters import org.librarysimplified.ui.navigation.tabs.TabbedNavigator +import org.librarysimplified.viewer.audiobook.AudioBookPlayerActivity2 import org.nypl.simplified.accounts.api.AccountEvent import org.nypl.simplified.accounts.api.AccountEventDeletion import org.nypl.simplified.accounts.api.AccountEventUpdated @@ -24,14 +28,10 @@ import org.nypl.simplified.books.api.BookID import org.nypl.simplified.books.book_registry.BookHoldsUpdateEvent import org.nypl.simplified.books.book_registry.BookStatus import org.nypl.simplified.books.book_registry.BookStatusEvent -import org.nypl.simplified.deeplinks.controller.api.DeepLinkEvent -import org.nypl.simplified.deeplinks.controller.api.ScreenID import org.nypl.simplified.listeners.api.ListenerRepository import org.nypl.simplified.listeners.api.listenerRepositories import org.nypl.simplified.profiles.api.ProfileEvent import org.nypl.simplified.profiles.api.ProfileUpdated -import org.nypl.simplified.ui.accounts.AccountListFragment -import org.nypl.simplified.ui.accounts.AccountListFragmentParameters import org.nypl.simplified.ui.announcements.AnnouncementsDialog import org.slf4j.LoggerFactory import java.net.URI @@ -124,6 +124,7 @@ class MainFragment : Fragment(R.layout.main_tabbed_host) { android.R.id.home -> { this.navigator.popToRoot() } + else -> super.onOptionsItemSelected(item) } } @@ -143,10 +144,6 @@ class MainFragment : Fragment(R.layout.main_tabbed_host) { .subscribe(this::onBookStatusEvent) .let { subscriptions.add(it) } - viewModel.deepLinkEvents - .subscribe(this::onDeepLinkEvent) - .let { subscriptions.add(it) } - viewModel.bookHoldEvents .subscribe(this::onBookHoldsUpdateEvent) .let { subscriptions.add(it) } @@ -159,6 +156,34 @@ class MainFragment : Fragment(R.layout.main_tabbed_host) { this.requireActivity(), Services.serviceDirectory() ) + + this.showAudioBookPlayerIfNecessary() + } + + /** + * XXX: This is really not what we want to be doing, but for PP-1612, we don't have enough + * of a UI yet to cope with the fact that an audiobook might be playing in the background + * when the application is resumed. When the MainFragment is resumed, we need to check to + * see if a player is playing and, if it is, open the player activity. + * + * This can be obliterated when the catalog is rewritten and we move to having a single + * activity for the entire application. + */ + + private fun showAudioBookPlayerIfNecessary() { + when (PlayerModel.state) { + is PlayerModelState.PlayerOpen -> { + if (PlayerModel.isPlaying) { + this.activity?.startActivity( + Intent(activity, AudioBookPlayerActivity2::class.java) + ) + } + } + + else -> { + // Ignore + } + } } private fun onAccountEvent(event: AccountEvent) { @@ -178,7 +203,7 @@ class MainFragment : Fragment(R.layout.main_tabbed_host) { try { this.navigator.clearHistory() } catch (e: Throwable) { - this.logger.error("could not clear history: ", e) + this.logger.debug("could not clear history: ", e) } } } @@ -241,22 +266,6 @@ class MainFragment : Fragment(R.layout.main_tabbed_host) { } } - private fun onDeepLinkEvent(event: DeepLinkEvent) { - if (event.screenID == ScreenID.LOGIN) { - this.navigator.addFragment( - fragment = AccountListFragment.create( - AccountListFragmentParameters( - shouldShowLibraryRegistryMenu = false, - accountID = event.accountID, - barcode = event.barcode, - comingFromDeepLink = true - ) - ), - tab = org.librarysimplified.ui.tabs.R.id.tabSettings - ) - } - } - private fun onBookHoldsUpdateEvent(event: BookHoldsUpdateEvent) { val numberOfHolds = event.numberOfHolds if (viewModel.showHoldsTab) { @@ -268,13 +277,16 @@ class MainFragment : Fragment(R.layout.main_tabbed_host) { if (numberOfHolds > 0) { if (badgeView == null) { badgeView = LayoutInflater.from(requireContext()).inflate( - org.librarysimplified.ui.tabs.R.layout.layout_menu_item_badge, bottomNavigationItem, false + org.librarysimplified.ui.tabs.R.layout.layout_menu_item_badge, + bottomNavigationItem, + false ) bottomNavigationItem.addView(badgeView) } val badgeNumber = (badgeView as? ViewGroup)?.findViewById( - org.librarysimplified.ui.tabs.R.id.badgeNumber) + org.librarysimplified.ui.tabs.R.id.badgeNumber + ) badgeNumber?.text = numberOfHolds.toString() } diff --git a/simplified-main/src/main/java/org/librarysimplified/main/MainFragmentViewModel.kt b/simplified-main/src/main/java/org/librarysimplified/main/MainFragmentViewModel.kt index fb6d8693d..6eda4c108 100644 --- a/simplified-main/src/main/java/org/librarysimplified/main/MainFragmentViewModel.kt +++ b/simplified-main/src/main/java/org/librarysimplified/main/MainFragmentViewModel.kt @@ -18,8 +18,6 @@ import org.nypl.simplified.books.book_registry.BookHoldsUpdateEvent import org.nypl.simplified.books.book_registry.BookRegistryType import org.nypl.simplified.books.book_registry.BookStatusEvent import org.nypl.simplified.buildconfig.api.BuildConfigurationServiceType -import org.nypl.simplified.deeplinks.controller.api.DeepLinkEvent -import org.nypl.simplified.deeplinks.controller.api.DeepLinksControllerType import org.nypl.simplified.feeds.api.Feed import org.nypl.simplified.feeds.api.FeedBooksSelection import org.nypl.simplified.feeds.api.FeedEntry @@ -52,8 +50,6 @@ class MainFragmentViewModel( UnicastWorkSubject.create() val registryEvents: UnicastWorkSubject = UnicastWorkSubject.create() - val deepLinkEvents: UnicastWorkSubject = - UnicastWorkSubject.create() val bookHoldEvents: UnicastWorkSubject = UnicastWorkSubject.create() @@ -65,8 +61,6 @@ class MainFragmentViewModel( services.requireService(AccountProviderRegistryType::class.java) val bookRegistry: BookRegistryType = services.requireService(BookRegistryType::class.java) - val deepLinksController: DeepLinksControllerType = - services.requireService(DeepLinksControllerType::class.java) val buildConfig = services.requireService(BuildConfigurationServiceType::class.java) val showHoldsTab: Boolean @@ -189,17 +183,6 @@ class MainFragmentViewModel( registryEvents.onNext(event) } - init { - this.deepLinksController.deepLinkEvents() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::onDeepLinkEvent) - .let { subscriptions.add(it) } - } - - private fun onDeepLinkEvent(event: DeepLinkEvent) { - deepLinkEvents.onNext(event) - } - override fun onCleared() { super.onCleared() subscriptions.clear() diff --git a/simplified-main/src/main/java/org/librarysimplified/main/MainServices.kt b/simplified-main/src/main/java/org/librarysimplified/main/MainServices.kt index d9b023ea6..2977febb4 100644 --- a/simplified-main/src/main/java/org/librarysimplified/main/MainServices.kt +++ b/simplified-main/src/main/java/org/librarysimplified/main/MainServices.kt @@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.squareup.picasso.Picasso import io.reactivex.subjects.PublishSubject import org.joda.time.LocalDateTime +import org.librarysimplified.audiobook.views.PlayerModel import org.librarysimplified.documents.DocumentConfigurationServiceType import org.librarysimplified.documents.DocumentStoreType import org.librarysimplified.documents.DocumentStores @@ -79,7 +80,6 @@ import org.nypl.simplified.buildconfig.api.BuildConfigurationServiceType import org.nypl.simplified.content.api.ContentResolverSane import org.nypl.simplified.content.api.ContentResolverType import org.nypl.simplified.crashlytics.api.CrashlyticsServiceType -import org.nypl.simplified.deeplinks.controller.api.DeepLinksControllerType import org.nypl.simplified.feeds.api.FeedHTTPTransport import org.nypl.simplified.feeds.api.FeedLoader import org.nypl.simplified.feeds.api.FeedLoaderType @@ -120,6 +120,8 @@ import org.slf4j.LoggerFactory import java.io.File import java.io.FileNotFoundException import java.io.IOException +import java.time.OffsetDateTime +import java.time.ZoneOffset.UTC import java.util.ServiceLoader internal object MainServices { @@ -144,7 +146,9 @@ internal object MainServices { val directoryStorageDownloads: File, val directoryStorageDocuments: File, val directoryStorageProfiles: File, - val directoryStorageTimeTracking: File + val directoryStorageTimeTrackingSender: File, + val directoryStorageTimeTrackingCollector: File, + val directoryStorageTimeTrackingDebug: File ) private fun initializeDirectories(context: Application): Directories { @@ -160,12 +164,21 @@ internal object MainServices { File(directoryStorageBaseVersioned, "profiles") val directoryStorageTimeTracking = File(directoryStorageBaseVersioned, "time_tracking") - - this.logger.debug("directoryStorageBaseVersioned: {}", directoryStorageBaseVersioned) - this.logger.debug("directoryStorageDownloads: {}", directoryStorageDownloads) - this.logger.debug("directoryStorageDocuments: {}", directoryStorageDocuments) - this.logger.debug("directoryStorageProfiles: {}", directoryStorageProfiles) - this.logger.debug("directoryStorageTimeTracking: {}", directoryStorageTimeTracking) + val directoryStorageTimeTrackingDebug = + File(directoryStorageTimeTracking, "debug") + val directoryStorageTimeTrackingSender = + File(directoryStorageTimeTracking, "sender") + val directoryStorageTimeTrackingCollector = + File(directoryStorageTimeTracking, "collector") + + this.logger.debug("directoryStorageBaseVersioned: {}", directoryStorageBaseVersioned) + this.logger.debug("directoryStorageDownloads: {}", directoryStorageDownloads) + this.logger.debug("directoryStorageDocuments: {}", directoryStorageDocuments) + this.logger.debug("directoryStorageProfiles: {}", directoryStorageProfiles) + this.logger.debug("directoryStorageTimeTracking: {}", directoryStorageTimeTracking) + this.logger.debug("directoryStorageTimeTrackingDebug: {}", directoryStorageTimeTrackingDebug) + this.logger.debug("directoryStorageTimeTrackingSender: {}", directoryStorageTimeTrackingSender) + this.logger.debug("directoryStorageTimeTrackingCollector: {}", directoryStorageTimeTrackingCollector) /* * Make sure the required directories exist. There is no sane way to @@ -178,7 +191,10 @@ internal object MainServices { directoryStorageDownloads, directoryStorageDocuments, directoryStorageProfiles, - directoryStorageTimeTracking + directoryStorageTimeTracking, + directoryStorageTimeTrackingSender, + directoryStorageTimeTrackingCollector, + directoryStorageTimeTrackingDebug ) var exception: Exception? = null @@ -203,7 +219,9 @@ internal object MainServices { directoryStorageDownloads = directoryStorageDownloads, directoryStorageDocuments = directoryStorageDocuments, directoryStorageProfiles = directoryStorageProfiles, - directoryStorageTimeTracking = directoryStorageTimeTracking + directoryStorageTimeTrackingDebug = directoryStorageTimeTrackingDebug, + directoryStorageTimeTrackingSender = directoryStorageTimeTrackingSender, + directoryStorageTimeTrackingCollector = directoryStorageTimeTrackingCollector ) } @@ -908,11 +926,6 @@ internal object MainServices { } ) - addService( - message = strings.bootingGeneral("deep links controller"), - interfaceType = DeepLinksControllerType::class.java, - serviceConstructor = { controller } - ) addService( message = strings.bootingGeneral("books controller"), interfaceType = BooksControllerType::class.java, @@ -930,11 +943,14 @@ internal object MainServices { message = strings.bootingGeneral("audiobook time tracker registry"), interfaceType = TimeTrackingServiceType::class.java, serviceConstructor = { - TimeTrackingService( - context = context, - httpCalls = TimeTrackingHTTPCalls(ObjectMapper(), lsHTTP, crashlyticsService), - timeTrackingDirectory = directories.directoryStorageTimeTracking, - profilesController = profilesControllerTypeService + TimeTrackingService.create( + profiles = profilesControllerTypeService, + httpCalls = TimeTrackingHTTPCalls(lsHTTP), + clock = { OffsetDateTime.now(UTC) }, + timeSegments = PlayerModel.timeTracker.timeSegments, + debugDirectory = directories.directoryStorageTimeTrackingDebug.toPath(), + collectorDirectory = directories.directoryStorageTimeTrackingCollector.toPath(), + senderDirectory = directories.directoryStorageTimeTrackingSender.toPath() ) } ) diff --git a/simplified-main/src/main/res/xml/provider_paths.xml b/simplified-main/src/main/res/xml/provider_paths.xml index 0da1fc363..abec11b3b 100644 --- a/simplified-main/src/main/res/xml/provider_paths.xml +++ b/simplified-main/src/main/res/xml/provider_paths.xml @@ -1,6 +1,9 @@ - + + diff --git a/simplified-migration-api/src/main/java/org/nypl/simplified/migration/api/Migrations.kt b/simplified-migration-api/src/main/java/org/nypl/simplified/migration/api/Migrations.kt index 13d1464e8..fbc9b6ef0 100644 --- a/simplified-migration-api/src/main/java/org/nypl/simplified/migration/api/Migrations.kt +++ b/simplified-migration-api/src/main/java/org/nypl/simplified/migration/api/Migrations.kt @@ -42,7 +42,7 @@ class Migrations( provider.create(this.serviceDependencies) } } catch (e: Exception) { - this.logger.error("could not setup migration service provider: ", e) + this.logger.debug("could not setup migration service provider: ", e) this.migrationServices = listOf() } } diff --git a/simplified-notifications/src/main/java/NotificationTokenHTTPCalls.kt b/simplified-notifications/src/main/java/NotificationTokenHTTPCalls.kt index 4619690a1..b0c26e3cf 100644 --- a/simplified-notifications/src/main/java/NotificationTokenHTTPCalls.kt +++ b/simplified-notifications/src/main/java/NotificationTokenHTTPCalls.kt @@ -100,7 +100,7 @@ class NotificationTokenHTTPCalls( } } .addOnFailureListener { exception -> - logger.error("Failed to fetch Firebase token: ", exception) + logger.debug("Failed to fetch Firebase token: ", exception) } } @@ -158,7 +158,7 @@ class NotificationTokenHTTPCalls( } } .addOnFailureListener { exception -> - logger.error("Failed to fetch Firebase token: ", exception) + logger.debug("Failed to fetch Firebase token: ", exception) } } diff --git a/simplified-oauth/src/main/java/org/nypl/simplified/oauth/OAuthCallbackIntentParsing.kt b/simplified-oauth/src/main/java/org/nypl/simplified/oauth/OAuthCallbackIntentParsing.kt index 6d997adc7..2b5d0b6b5 100644 --- a/simplified-oauth/src/main/java/org/nypl/simplified/oauth/OAuthCallbackIntentParsing.kt +++ b/simplified-oauth/src/main/java/org/nypl/simplified/oauth/OAuthCallbackIntentParsing.kt @@ -73,7 +73,7 @@ object OAuthCallbackIntentParsing { ) } } catch (e: Exception) { - this.logger.error("failure parsing intent: ", e) + this.logger.debug("failure parsing intent: ", e) OAuthParseResult.Failed("Failed to parse intent: " + e.message) } } diff --git a/simplified-profiles/src/main/java/org/nypl/simplified/profiles/ProfilesDatabases.kt b/simplified-profiles/src/main/java/org/nypl/simplified/profiles/ProfilesDatabases.kt index 6b7e2ec14..ce94fecc7 100644 --- a/simplified-profiles/src/main/java/org/nypl/simplified/profiles/ProfilesDatabases.kt +++ b/simplified-profiles/src/main/java/org/nypl/simplified/profiles/ProfilesDatabases.kt @@ -96,7 +96,7 @@ object ProfilesDatabases { if (errors.isNotEmpty()) { for (e in errors) { - this.logger.error("error during profile database open: ", e) + this.logger.debug("error during profile database open: ", e) } throw ProfileDatabaseOpenException( diff --git a/simplified-reports/build.gradle.kts b/simplified-reports/build.gradle.kts index 64ffc26a1..0168dd60c 100644 --- a/simplified-reports/build.gradle.kts +++ b/simplified-reports/build.gradle.kts @@ -20,6 +20,7 @@ android { dependencies { implementation(libs.androidx.core) + implementation(libs.commons.io) implementation(libs.kotlin.stdlib) implementation(libs.slf4j) } diff --git a/simplified-reports/src/main/java/org/librarysimplified/reports/Reports.kt b/simplified-reports/src/main/java/org/librarysimplified/reports/Reports.kt index 498dd6ba8..602011ff7 100644 --- a/simplified-reports/src/main/java/org/librarysimplified/reports/Reports.kt +++ b/simplified-reports/src/main/java/org/librarysimplified/reports/Reports.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import androidx.core.content.FileProvider +import org.apache.commons.io.FileUtils import org.librarysimplified.reports.Reports.Result.NoFiles import org.librarysimplified.reports.Reports.Result.RaisedException import org.librarysimplified.reports.Reports.Result.Sent @@ -60,26 +61,30 @@ object Reports { subject: String, body: String ): Result { - val directories: List = context.cacheDir?.let { cacheDir -> - arrayListOf(cacheDir, File(cacheDir, "migrations")) - } ?: emptyList() - - return sendReport( + return this.sendReport( context = context, - baseDirectories = directories, + baseDirectories = listOf(context.filesDir, context.cacheDir), address = address, subject = subject, body = body, - includeFile = this::isLogFileOrMigrationReport + includeFile = this::isSuitableForSending ) } @JvmStatic - private fun isLogFileOrMigrationReport(name: String): Boolean { + private fun isSuitableForSending( + name: String + ): Boolean { + if (name.startsWith("log.txt")) { + return true + } + if (name.equals("time_tracking_debug.dat")) { + return true + } if (name.startsWith("report-") && name.endsWith(".xml")) { return true } - return name.startsWith("log.txt") + return false } /** @@ -95,24 +100,24 @@ object Reports { body: String, includeFile: (String) -> Boolean ): Result { - logger.debug("preparing report") + this.logger.debug("preparing report") try { val files = - collectFiles(baseDirectories, includeFile) + this.collectFiles(baseDirectories, includeFile) val compressedFiles = files.mapNotNull(this::compressFile) val contentUris = - compressedFiles.toSet().map { file -> mapFileToContentURI(context, file) } + compressedFiles.toSet().map { file -> this.mapFileToContentURI(context, file) } - logger.debug("attaching {} files", compressedFiles.size) + this.logger.debug("attaching {} files", compressedFiles.size) return if (compressedFiles.isNotEmpty()) { val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { this.type = "text/plain" this.putExtra(Intent.EXTRA_EMAIL, arrayOf(address)) - this.putExtra(Intent.EXTRA_SUBJECT, extendSubject(context, subject)) - this.putExtra(Intent.EXTRA_TEXT, extendBody(body)) + this.putExtra(Intent.EXTRA_SUBJECT, this@Reports.extendSubject(context, subject)) + this.putExtra(Intent.EXTRA_TEXT, this@Reports.extendBody(body)) this.putExtra(Intent.EXTRA_STREAM, ArrayList(contentUris)) this.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) this.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @@ -123,7 +128,7 @@ object Reports { NoFiles } } catch (e: Exception) { - logger.error("failed to send report: ", e) + this.logger.debug("failed to send report: ", e) return RaisedException(e) } } @@ -150,7 +155,7 @@ object Reports { val pkgInfo = try { pkgManager.getPackageInfo(context.packageName, 0) } catch (e: PackageManager.NameNotFoundException) { - logger.error("unable to retrieve package information: ", e) + this.logger.debug("unable to retrieve package information: ", e) return subject } @@ -174,19 +179,24 @@ object Reports { ): MutableList { val files = mutableListOf() for (baseDirectory in baseDirectories) { - val list = baseDirectory.absoluteFile.list() ?: emptyArray() + val list = FileUtils.listFiles( + baseDirectory.absoluteFile, + null, + true + ) + for (file in list) { - val filePath = File(baseDirectory, file) - if (includeFile.invoke(file) && filePath.isFile) { - logger.debug("including {}", file) + val filePath = file.absoluteFile + if (includeFile.invoke(file.name) && filePath.isFile) { + this.logger.debug("including {}", file) files.add(filePath) } else { - logger.debug("excluding {}", file) + this.logger.debug("excluding {}", file) } } } - logger.debug("collected {} files", files.size) + this.logger.debug("collected {} files", files.size) return files } @@ -205,14 +215,14 @@ object Reports { inputStream.copyTo(zStream) zStream.finish() zStream.flush() - logger.debug("compressed {}", file) + this.logger.debug("compressed {}", file) fileGz } } } } } catch (e: Exception) { - logger.error("could not compress: {}: ", file, e) + this.logger.error("could not compress: {}: ", file, e) null } } diff --git a/simplified-services-api/src/main/java/org/librarysimplified/services/api/Services.kt b/simplified-services-api/src/main/java/org/librarysimplified/services/api/Services.kt index 60d0a8a49..387140fda 100644 --- a/simplified-services-api/src/main/java/org/librarysimplified/services/api/Services.kt +++ b/simplified-services-api/src/main/java/org/librarysimplified/services/api/Services.kt @@ -16,7 +16,7 @@ object Services : ServiceDirectoryProviderType { try { return this.servicesFuture.get(30L, TimeUnit.SECONDS) } catch (e: Exception) { - this.logger.error("unable to fetch service directory: ", e) + this.logger.debug("unable to fetch service directory: ", e) throw e } } diff --git a/simplified-tests/build.gradle.kts b/simplified-tests/build.gradle.kts index 05a153396..d95e401ed 100644 --- a/simplified-tests/build.gradle.kts +++ b/simplified-tests/build.gradle.kts @@ -57,7 +57,6 @@ val dependencyObjects = listOf( project(":simplified-content-api"), project(":simplified-crashlytics"), project(":simplified-crashlytics-api"), - project(":simplified-deeplinks-controller-api"), project(":simplified-documents"), project(":simplified-feeds-api"), project(":simplified-files"), @@ -141,11 +140,6 @@ val dependencyObjects = listOf( libs.androidx.lifecycle.viewmodel, libs.androidx.lifecycle.viewmodel.savedstate, libs.androidx.media, - libs.media3.common, - libs.media3.datasource, - libs.media3.exoplayer, - libs.media3.extractor, - libs.media3.session, libs.androidx.paging.common, libs.androidx.paging.common.ktx, libs.androidx.paging.runtime, @@ -158,9 +152,12 @@ val dependencyObjects = listOf( libs.androidx.viewpager2, libs.androidx.webkit, libs.apiguardian, + libs.azam.ulidj, libs.bouncycastle.bcprov, libs.bytebuddy, libs.bytebuddy.agent, + libs.commons.compress, + libs.commons.io, libs.conscrypt, libs.firebase.analytics, libs.firebase.crashlytics, @@ -172,10 +169,11 @@ val dependencyObjects = listOf( libs.google.guava, libs.google.material, libs.hamcrest, + libs.io7m.jattribute.core, libs.io7m.jfunctional, + libs.io7m.jmulticlose, libs.io7m.jnull, libs.io7m.junreachable, - libs.kabstand, libs.irradia.fieldrush.api, libs.irradia.fieldrush.vanilla, libs.irradia.mime.api, @@ -200,12 +198,18 @@ val dependencyObjects = listOf( libs.junit.jupiter.vintage, libs.junit.platform.commons, libs.junit.platform.engine, + libs.kabstand, libs.kotlin.reflect, libs.kotlin.stdlib, libs.kotlinx.coroutines, libs.kotlinx.datetime, libs.logback.classic, libs.logback.core, + libs.media3.common, + libs.media3.datasource, + libs.media3.exoplayer, + libs.media3.extractor, + libs.media3.session, libs.mockito.android, libs.mockito.core, libs.mockito.kotlin, @@ -224,6 +228,7 @@ val dependencyObjects = listOf( libs.palace.audiobook.downloads, libs.palace.audiobook.feedbooks, libs.palace.audiobook.http, + libs.palace.audiobook.lcp.downloads, libs.palace.audiobook.lcp.license.status, libs.palace.audiobook.license.check.api, libs.palace.audiobook.license.check.spi, @@ -237,6 +242,7 @@ val dependencyObjects = listOf( libs.palace.audiobook.manifest.parser.webpub, libs.palace.audiobook.media3, libs.palace.audiobook.parser.api, + libs.palace.audiobook.time.tracking, libs.palace.audiobook.views, libs.palace.drm.core, libs.palace.http.api, @@ -280,7 +286,6 @@ val dependencyObjects = listOf( libs.rxjava2.extensions, libs.slf4j, libs.transifex.sdk, - libs.azam.ulidj, ) dependencies { diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/bookmark_annotations/ReaderBookmarkAnnotationsJSONTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/bookmark_annotations/ReaderBookmarkAnnotationsJSONTest.kt index 9259e3da8..f72f6c6b8 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/bookmark_annotations/ReaderBookmarkAnnotationsJSONTest.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/bookmark_annotations/ReaderBookmarkAnnotationsJSONTest.kt @@ -24,7 +24,9 @@ import org.nypl.simplified.books.api.bookmark.SerializedLocatorLegacyCFI import org.nypl.simplified.books.api.bookmark.SerializedLocatorPage1 import org.nypl.simplified.books.api.bookmark.SerializedLocators import org.nypl.simplified.json.core.JSONParseException +import org.nypl.simplified.json.core.JSONParserUtilities import org.slf4j.LoggerFactory +import java.io.File import java.io.FileNotFoundException import java.io.InputStream @@ -136,13 +138,19 @@ class ReaderBookmarkAnnotationsJSONTest { assertEquals("oa:FragmentSelector", node["type"].textValue()) assertEquals(this.targetValue0, node["value"].textValue()) - assertEquals(input, BookmarkAnnotationsJSON.deserializeSelectorNodeFromJSON(this.objectMapper, node)) + assertEquals( + input, + BookmarkAnnotationsJSON.deserializeSelectorNodeFromJSON(this.objectMapper, node) + ) } @Test fun testTarget() { val input = - BookmarkAnnotationTargetNode("z", BookmarkAnnotationSelectorNode("oa:FragmentSelector", this.targetValue0)) + BookmarkAnnotationTargetNode( + "z", + BookmarkAnnotationSelectorNode("oa:FragmentSelector", this.targetValue0) + ) val node = BookmarkAnnotationsJSON.serializeTargetNodeToJSON(this.objectMapper, input) @@ -150,7 +158,10 @@ class ReaderBookmarkAnnotationsJSONTest { assertEquals("oa:FragmentSelector", node["selector"]["type"].textValue()) assertEquals(this.targetValue0, node["selector"]["value"].textValue()) - assertEquals(input, BookmarkAnnotationsJSON.deserializeTargetNodeFromJSON(this.objectMapper, node)) + assertEquals( + input, + BookmarkAnnotationsJSON.deserializeTargetNodeFromJSON(this.objectMapper, node) + ) } @Test @@ -186,7 +197,10 @@ class ReaderBookmarkAnnotationsJSONTest { @Test fun testBookmark() { val target = - BookmarkAnnotationTargetNode("z", BookmarkAnnotationSelectorNode("oa:FragmentSelector", this.targetValue0)) + BookmarkAnnotationTargetNode( + "z", + BookmarkAnnotationSelectorNode("oa:FragmentSelector", this.targetValue0) + ) val input = BookmarkAnnotation( @@ -201,7 +215,10 @@ class ReaderBookmarkAnnotationsJSONTest { val node = BookmarkAnnotationsJSON.serializeBookmarkAnnotationToJSON(this.objectMapper, input) - this.compareAnnotations(input, BookmarkAnnotationsJSON.deserializeBookmarkAnnotationFromJSON(this.objectMapper, node)) + this.compareAnnotations( + input, + BookmarkAnnotationsJSON.deserializeBookmarkAnnotationFromJSON(this.objectMapper, node) + ) } @Test @@ -218,7 +235,10 @@ class ReaderBookmarkAnnotationsJSONTest { assertEquals( input, - BookmarkAnnotationsJSON.deserializeBookmarkAnnotationFirstNodeFromJSON(this.objectMapper, node) + BookmarkAnnotationsJSON.deserializeBookmarkAnnotationFirstNodeFromJSON( + this.objectMapper, + node + ) ) } @@ -241,7 +261,10 @@ class ReaderBookmarkAnnotationsJSONTest { @Test fun testBookmarkBadDateSIMPLY_1938() { val target = - BookmarkAnnotationTargetNode("z", BookmarkAnnotationSelectorNode("oa:FragmentSelector", this.targetValue0)) + BookmarkAnnotationTargetNode( + "z", + BookmarkAnnotationSelectorNode("oa:FragmentSelector", this.targetValue0) + ) val input = BookmarkAnnotation( @@ -256,7 +279,10 @@ class ReaderBookmarkAnnotationsJSONTest { val node = BookmarkAnnotationsJSON.serializeBookmarkAnnotationToJSON(this.objectMapper, input) - this.compareAnnotations(input, BookmarkAnnotationsJSON.deserializeBookmarkAnnotationFromJSON(this.objectMapper, node)) + this.compareAnnotations( + input, + BookmarkAnnotationsJSON.deserializeBookmarkAnnotationFromJSON(this.objectMapper, node) + ) } @Test @@ -577,6 +603,45 @@ class ReaderBookmarkAnnotationsJSONTest { assertTrue(ex.message!!.contains("Expected: A key 'http://librarysimplified.org/terms/time'")) } + @Test + fun testAnnotationsDump20240805() { + val node = + this.resourceBookmarksNode("annotations-dump-20240805.json") + val first = + JSONParserUtilities.getObject(node, "first") + val items = + JSONParserUtilities.getArray(first, "items") + + val outputArray = + this.objectMapper.createArrayNode() + + var index = 0 + for (item in items) { + val itemObject = + item as ObjectNode + val target = + JSONParserUtilities.getObject(itemObject, "target") + val selector = + JSONParserUtilities.getObject(target, "selector") + val value = + JSONParserUtilities.getString(selector, "value") + + outputArray.add(value) + this.logger.debug("[{}]: {}", index, value) + ++index + } + + /* + * Write the escaped locators to a temporary file for inspection. + */ + + val outputFile = File("/tmp/output.json") + outputFile.outputStream().use { stream -> + objectMapper.writeValue(stream, outputArray) + stream.flush() + } + } + private fun resourceText( name: String ): String { @@ -591,7 +656,10 @@ class ReaderBookmarkAnnotationsJSONTest { private fun checkRoundTrip(bookmarkAnnotation: BookmarkAnnotation) { val serialized = - BookmarkAnnotationsJSON.serializeBookmarkAnnotationToBytes(this.objectMapper, bookmarkAnnotation) + BookmarkAnnotationsJSON.serializeBookmarkAnnotationToBytes( + this.objectMapper, + bookmarkAnnotation + ) val serializedText = serialized.decodeToString() @@ -654,4 +722,21 @@ class ReaderBookmarkAnnotationsJSONTest { ?: throw FileNotFoundException("No such resource: $fileName") return url.openStream() } + + private fun resourceBookmarks( + name: String + ): InputStream { + val fileName = + "/org/nypl/simplified/tests/bookmarks/$name" + val url = + ReaderBookmarkAnnotationsJSONTest::class.java.getResource(fileName) + ?: throw FileNotFoundException("No such resource: $fileName") + return url.openStream() + } + + private fun resourceBookmarksNode( + name: String + ): ObjectNode { + return this.objectMapper.readTree(this.resourceBookmarks(name)) as ObjectNode + } } diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/accounts/AccountsDatabaseContract.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/accounts/AccountsDatabaseContract.kt index 82dbe1875..7f3b59aff 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/accounts/AccountsDatabaseContract.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/accounts/AccountsDatabaseContract.kt @@ -66,7 +66,7 @@ abstract class AccountsDatabaseContract { override fun matches(item: Any): Boolean { if (item is AccountsDatabaseException) { for (c in item.causes()) { - this.logger.error("Cause: ", c) + this.logger.debug("Cause: ", c) if (this.exception_type.isAssignableFrom(c.javaClass) && c.message!!.contains(this.message)) { return true } diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookFailingParsers.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookFailingParsers.kt index c3acccf13..ff166e22d 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookFailingParsers.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookFailingParsers.kt @@ -2,6 +2,7 @@ package org.nypl.simplified.tests.books.audio import org.librarysimplified.audiobook.manifest.api.PlayerManifest import org.librarysimplified.audiobook.manifest_parser.api.ManifestParsersType +import org.librarysimplified.audiobook.manifest_parser.api.ManifestUnparsed import org.librarysimplified.audiobook.manifest_parser.extension_spi.ManifestParserExtensionType import org.librarysimplified.audiobook.parser.api.ParseError import org.librarysimplified.audiobook.parser.api.ParseResult @@ -13,7 +14,7 @@ object AudioBookFailingParsers : ManifestParsersType { override fun parse( uri: URI, - streams: ByteArray + input: ManifestUnparsed ): ParseResult { return ParseResult.Failure( warnings = listOf( @@ -32,7 +33,7 @@ object AudioBookFailingParsers : ManifestParsersType { override fun parse( uri: URI, - streams: ByteArray, + input: ManifestUnparsed, extensions: List ): ParseResult { return ParseResult.Failure( diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookManifestStrategyTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookManifestStrategyTest.kt index ca709716a..f44b0a8db 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookManifestStrategyTest.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookManifestStrategyTest.kt @@ -7,6 +7,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.librarysimplified.audiobook.api.PlayerResult import org.librarysimplified.audiobook.api.PlayerUserAgent +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID import org.librarysimplified.audiobook.manifest_fulfill.api.ManifestFulfillmentStrategyRegistryType import org.librarysimplified.audiobook.manifest_fulfill.basic.ManifestFulfillmentBasicParameters import org.librarysimplified.audiobook.manifest_fulfill.basic.ManifestFulfillmentBasicType @@ -83,6 +84,7 @@ class AudioBookManifestStrategyTest { credentials = null, httpClient = this.httpClient, isNetworkAvailable = { true }, + palaceID = PlayerPalaceID("6c15709a-b9cd-4eb8-815a-309f5d738a11"), services = this.services, strategyRegistry = this.strategies, target = AudioBookLink.Manifest(URI.create("http://www.example.com")), @@ -129,6 +131,7 @@ class AudioBookManifestStrategyTest { services = this.services, isNetworkAvailable = { true }, strategyRegistry = this.strategies, + palaceID = PlayerPalaceID("6c15709a-b9cd-4eb8-815a-309f5d738a11"), cacheDirectory = File(tempFolder, "cache") ) ) @@ -182,6 +185,7 @@ class AudioBookManifestStrategyTest { manifestParsers = AudioBookFailingParsers, extensions = emptyList(), httpClient = this.httpClient, + palaceID = PlayerPalaceID("6c15709a-b9cd-4eb8-815a-309f5d738a11"), cacheDirectory = File(tempFolder, "cache") ) ) @@ -226,18 +230,19 @@ class AudioBookManifestStrategyTest { AudioBookStrategy( context = this.context, request = AudioBookManifestRequest( - target = AudioBookLink.Manifest(URI.create("http://www.example.com")), + cacheDirectory = File(tempFolder, "cache"), contentType = BookFormats.audioBookGenericMimeTypes().first(), - userAgent = PlayerUserAgent("test"), credentials = null, - services = this.services, - isNetworkAvailable = { true }, - strategyRegistry = this.strategies, - manifestParsers = AudioBookSucceedingParsers, extensions = emptyList(), - licenseChecks = listOf(), httpClient = this.httpClient, - cacheDirectory = File(tempFolder, "cache") + isNetworkAvailable = { true }, + licenseChecks = listOf(), + manifestParsers = AudioBookSucceedingParsers, + palaceID = PlayerPalaceID("6c15709a-b9cd-4eb8-815a-309f5d738a11"), + services = this.services, + strategyRegistry = this.strategies, + target = AudioBookLink.Manifest(URI.create("http://www.example.com")), + userAgent = PlayerUserAgent("test"), ) ) @@ -251,14 +256,15 @@ class AudioBookManifestStrategyTest { AudioBookStrategy( context = this.context, request = AudioBookManifestRequest( - target = AudioBookLink.Manifest(URI.create("http://www.example.com")), + cacheDirectory = File(tempFolder, "cache"), contentType = BookFormats.audioBookGenericMimeTypes().first(), - userAgent = PlayerUserAgent("test"), credentials = null, - services = this.services, - isNetworkAvailable = { false }, httpClient = this.httpClient, - cacheDirectory = File(tempFolder, "cache") + isNetworkAvailable = { false }, + palaceID = PlayerPalaceID("6c15709a-b9cd-4eb8-815a-309f5d738a11"), + services = this.services, + target = AudioBookLink.Manifest(URI.create("http://www.example.com")), + userAgent = PlayerUserAgent("test"), ) ) @@ -291,6 +297,7 @@ class AudioBookManifestStrategyTest { strategyRegistry = this.strategies, licenseChecks = listOf(), httpClient = this.httpClient, + palaceID = PlayerPalaceID("6c15709a-b9cd-4eb8-815a-309f5d738a11"), cacheDirectory = File(tempFolder, "cache") ) ) diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookSucceedingParsers.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookSucceedingParsers.kt index 66a9ef8f2..49772b48b 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookSucceedingParsers.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookSucceedingParsers.kt @@ -5,7 +5,9 @@ import org.librarysimplified.audiobook.manifest.api.PlayerManifestLink import org.librarysimplified.audiobook.manifest.api.PlayerManifestMetadata import org.librarysimplified.audiobook.manifest.api.PlayerManifestReadingOrderID import org.librarysimplified.audiobook.manifest.api.PlayerManifestReadingOrderItem +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID import org.librarysimplified.audiobook.manifest_parser.api.ManifestParsersType +import org.librarysimplified.audiobook.manifest_parser.api.ManifestUnparsed import org.librarysimplified.audiobook.manifest_parser.extension_spi.ManifestParserExtensionType import org.librarysimplified.audiobook.parser.api.ParseResult import java.net.URI @@ -28,12 +30,13 @@ object AudioBookSucceedingParsers : ManifestParsersType { ), links = listOf(), extensions = listOf(), - toc = listOf() + toc = listOf(), + palaceId = PlayerPalaceID("c925eb26-ab0c-44e2-9bec-ca4c38c0b6c8") ) override fun parse( uri: URI, - streams: ByteArray + input: ManifestUnparsed ): ParseResult { return ParseResult.Success( warnings = listOf(), @@ -43,9 +46,9 @@ object AudioBookSucceedingParsers : ManifestParsersType { override fun parse( uri: URI, - streams: ByteArray, + input: ManifestUnparsed, extensions: List ): ParseResult { - return parse(uri, streams) + return parse(uri, input) } } diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowAudioBookTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowAudioBookTest.kt index 45ae2057c..e3213fcd5 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowAudioBookTest.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/borrowing/BorrowAudioBookTest.kt @@ -8,8 +8,10 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfilled import org.librarysimplified.audiobook.manifest_parser.api.ManifestParsers +import org.librarysimplified.audiobook.manifest_parser.api.ManifestUnparsed import org.librarysimplified.audiobook.parser.api.ParseResult import org.librarysimplified.http.api.LSHTTPClientConfiguration import org.librarysimplified.http.api.LSHTTPClientType @@ -211,7 +213,9 @@ class BorrowAudioBookTest { )!! .readBytes() val manifestResult = - ManifestParsers.parse(URI.create("urn:basic-manifest.json"), data) + ManifestParsers.parse( + URI.create("urn:basic-manifest.json"), + ManifestUnparsed(PlayerPalaceID("6c15709a-b9cd-4eb8-815a-309f5d738a11"), data)) as ParseResult.Success val manifest = manifestResult.result diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/controller/BooksControllerContract.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/controller/BooksControllerContract.kt index 1388eadab..467027a57 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/controller/BooksControllerContract.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/controller/BooksControllerContract.kt @@ -396,55 +396,6 @@ abstract class BooksControllerContract { Assertions.assertEquals(IOException::class.java, (result as TaskResult.Failure).exception!!.javaClass) } - /** - * If the remote side returns a 401 error code, the current credentials should be thrown away. - */ - - @Test - @Timeout(value = 3L, unit = TimeUnit.SECONDS) - @Throws(Exception::class) - fun testBooksSyncRemote401() { - val controller = - createController( - exec = this.executorBooks, - feedExecutor = this.executorFeeds, - accountEvents = this.accountEvents, - profileEvents = this.profileEvents, - http = this.lsHTTP, - books = this.bookRegistry, - profiles = this.profiles, - accountProviders = MockAccountProviders.fakeAccountProviders(), - patronUserProfileParsers = this.patronUserProfileParsers - ) - - val provider = - MockAccountProviders.fakeAuthProvider( - uri = "urn:fake-auth:0", - host = this.server.hostName, - port = this.server.port - ) - - val profile = this.profiles.createProfile(provider, "Kermit") - this.profiles.setProfileCurrent(profile.id) - val account = profile.accountsByProvider()[provider.id]!! - account.setLoginState(AccountLoggedIn(correctCredentials())) - - this.server.enqueue( - MockResponse() - .setResponseCode(200) - .setBody(this.simpleUserProfile()) - ) - - this.server.enqueue( - MockResponse() - .setResponseCode(401) - .setBody("") - ) - - controller.booksSync(account.id).get() - Assertions.assertEquals(AccountNotLoggedIn, account.loginState) - } - /** * If the provider does not support authentication, then syncing is impossible and does nothing. * diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/time_tracking/TimeTrackingCollectorTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/time_tracking/TimeTrackingCollectorTest.kt new file mode 100644 index 000000000..22b3c4532 --- /dev/null +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/time_tracking/TimeTrackingCollectorTest.kt @@ -0,0 +1,252 @@ +package org.nypl.simplified.tests.books.time_tracking + +import com.io7m.jattribute.core.AttributeType +import com.io7m.jattribute.core.Attributes +import com.io7m.jmulticlose.core.CloseableCollection +import com.io7m.jmulticlose.core.CloseableCollectionType +import com.io7m.jmulticlose.core.ClosingResourceFailedException +import io.reactivex.subjects.PublishSubject +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import org.junit.jupiter.api.io.TempDir +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID +import org.librarysimplified.audiobook.time_tracking.PlayerTimeTracked +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.nypl.simplified.accounts.api.AccountID +import org.nypl.simplified.accounts.api.AccountProviderType +import org.nypl.simplified.accounts.database.api.AccountType +import org.nypl.simplified.books.time.tracking.TimeTrackingCollector +import org.nypl.simplified.books.time.tracking.TimeTrackingCollectorServiceType +import org.nypl.simplified.books.time.tracking.TimeTrackingReceivedSpan +import org.nypl.simplified.books.time.tracking.TimeTrackingStatus +import org.nypl.simplified.profiles.api.ProfileType +import org.nypl.simplified.profiles.controller.api.ProfilesControllerType +import org.slf4j.LoggerFactory +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.time.OffsetDateTime +import java.util.UUID +import java.util.concurrent.TimeUnit +import java.util.stream.Collectors + +class TimeTrackingCollectorTest { + + private val logger = + LoggerFactory.getLogger(TimeTrackingCollectorTest::class.java) + + private val accountID = + AccountID(UUID.randomUUID()) + private val palaceID = + PlayerPalaceID("cbd92367-f3e1-4310-a767-a07058271c2b") + private val palaceIDWrong = + PlayerPalaceID("e1cc010f-e88d-4c16-a12e-ab7b9f5c2065") + + private lateinit var profiles: ProfilesControllerType + private lateinit var resources: CloseableCollectionType + private lateinit var collector: TimeTrackingCollectorServiceType + private lateinit var inboxDirectory: Path + private lateinit var debugDirectory: Path + private lateinit var timeSegments: PublishSubject + private lateinit var status: AttributeType + private lateinit var accountRef: AccountType + private lateinit var profileRef: ProfileType + private lateinit var accountProviderRef: AccountProviderType + + @BeforeEach + fun setup( + @TempDir debugDirectory: Path, + @TempDir inboxDirectory: Path + ) { + this.resources = CloseableCollection.create() + this.debugDirectory = debugDirectory + this.inboxDirectory = inboxDirectory + + this.profiles = + Mockito.mock(ProfilesControllerType::class.java) + this.accountRef = + Mockito.mock(AccountType::class.java) + this.profileRef = + Mockito.mock(ProfileType::class.java) + this.accountProviderRef = + Mockito.mock(AccountProviderType::class.java) + + Mockito.`when`(this.profiles.profileCurrent()) + .thenReturn(this.profileRef) + Mockito.`when`(this.profileRef.account(any())) + .thenReturn(this.accountRef) + Mockito.`when`(this.accountRef.provider) + .thenReturn(this.accountProviderRef) + Mockito.`when`(this.accountProviderRef.id) + .thenReturn(URI.create("urn:uuid:d36a27ab-acd4-4e49-b2cc-19086c780cfb")) + + this.status = + Attributes.create { _ -> } + .withValue(TimeTrackingStatus.Inactive) + + this.timeSegments = + PublishSubject.create() + + this.resources.add(AutoCloseable { this.timeSegments.onComplete() }) + + this.collector = + TimeTrackingCollector.create( + profiles = this.profiles, + status = this.status, + timeSegments = this.timeSegments, + debugDirectory = debugDirectory, + outputDirectory = inboxDirectory + ) + + this.resources.add(AutoCloseable { this.collector.close() }) + } + + @AfterEach + fun tearDown() + { + this.resources.close() + } + + @Test + @Timeout(value = 5L, unit = TimeUnit.SECONDS) + fun testCollectorRecordsSpans() { + this.status.set(TimeTrackingStatus.Active( + accountID = this.accountID, + bookId = this.palaceID, + libraryId = "1014c482-0629-4a57-87ae-f9cc6e933397", + timeTrackingUri = URI.create("http://www.example.com") + )) + + this.timeSegments.onNext( + PlayerTimeTracked.create( + id = UUID.fromString("0af973ee-9a2a-4ac3-a330-d8baee101df7"), + bookTrackingId = this.palaceID, + timeStarted = OffsetDateTime.parse("2024-10-10T00:00:00Z"), + timeEnded = OffsetDateTime.parse("2024-10-10T00:00:30Z"), + rate = 1.0 + ) + ) + + this.collector.awaitWrite(1L, TimeUnit.SECONDS) + + val fileExpected = + this.inboxDirectory.resolve("0af973ee-9a2a-4ac3-a330-d8baee101df7.ttspan") + + val filesNow = Files.list(this.inboxDirectory).collect(Collectors.toUnmodifiableList()) + this.logger.debug("Files now: {}", filesNow) + if (!filesNow.contains(fileExpected)) { + throw IllegalStateException("Files not written!") + } + + val o = TimeTrackingReceivedSpan.ofFile(fileExpected) + assertEquals( + OffsetDateTime.parse("2024-10-10T00:00:00Z"), + o.timeStarted + ) + assertEquals( + OffsetDateTime.parse("2024-10-10T00:00:30Z"), + o.timeEnded + ) + assertEquals( + UUID.fromString("0af973ee-9a2a-4ac3-a330-d8baee101df7"), + o.id + ) + assertEquals( + this.palaceID, + o.bookID + ) + assertEquals( + this.accountID, + o.accountID + ) + assertEquals( + URI.create("http://www.example.com"), + o.targetURI + ) + } + + @Test + @Timeout(value = 5L, unit = TimeUnit.SECONDS) + fun testCollectorIgnoresInactiveSpans() { + this.timeSegments.onNext( + PlayerTimeTracked.create( + id = UUID.fromString("0af973ee-9a2a-4ac3-a330-d8baee101df7"), + bookTrackingId = this.palaceID, + timeStarted = OffsetDateTime.parse("2024-10-10T00:00:00Z"), + timeEnded = OffsetDateTime.parse("2024-10-10T00:00:30Z"), + rate = 1.0 + ) + ) + + Thread.sleep(1_000L) + + val filesNow = Files.list(this.inboxDirectory).collect(Collectors.toUnmodifiableList()) + this.logger.debug("Files now: {}", filesNow) + if (filesNow.isNotEmpty()) { + throw IllegalStateException("File created!") + } + } + + @Test + @Timeout(value = 5L, unit = TimeUnit.SECONDS) + fun testCollectorIgnoresInactiveSpansAfterActivity() { + this.status.set(TimeTrackingStatus.Active( + accountID = this.accountID, + bookId = this.palaceID, + libraryId = "1014c482-0629-4a57-87ae-f9cc6e933397", + timeTrackingUri = URI.create("http://www.example.com") + )) + this.status.set(TimeTrackingStatus.Inactive) + + this.timeSegments.onNext( + PlayerTimeTracked.create( + id = UUID.fromString("0af973ee-9a2a-4ac3-a330-d8baee101df7"), + bookTrackingId = this.palaceID, + timeStarted = OffsetDateTime.parse("2024-10-10T00:00:00Z"), + timeEnded = OffsetDateTime.parse("2024-10-10T00:00:30Z"), + rate = 1.0 + ) + ) + + Thread.sleep(1_000L) + + val filesNow = Files.list(this.inboxDirectory).collect(Collectors.toUnmodifiableList()) + this.logger.debug("Files now: {}", filesNow) + if (filesNow.isNotEmpty()) { + throw IllegalStateException("File created!") + } + } + + @Test + @Timeout(value = 5L, unit = TimeUnit.SECONDS) + fun testCollectorIgnoresSpansForWrongBook() { + this.status.set(TimeTrackingStatus.Active( + accountID = this.accountID, + bookId = this.palaceID, + libraryId = "1014c482-0629-4a57-87ae-f9cc6e933397", + timeTrackingUri = URI.create("http://www.example.com") + )) + + this.timeSegments.onNext( + PlayerTimeTracked.create( + id = UUID.fromString("0af973ee-9a2a-4ac3-a330-d8baee101df7"), + bookTrackingId = this.palaceIDWrong, + timeStarted = OffsetDateTime.parse("2024-10-10T00:00:00Z"), + timeEnded = OffsetDateTime.parse("2024-10-10T00:00:30Z"), + rate = 1.0 + ) + ) + + Thread.sleep(1_000L) + + val filesNow = Files.list(this.inboxDirectory).collect(Collectors.toUnmodifiableList()) + this.logger.debug("Files now: {}", filesNow) + if (filesNow.isNotEmpty()) { + throw IllegalStateException("File created!") + } + } +} diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/time_tracking/TimeTrackingHttpCallsTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/time_tracking/TimeTrackingHttpCallsTest.kt index 6e20b82de..7895915c0 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/time_tracking/TimeTrackingHttpCallsTest.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/time_tracking/TimeTrackingHttpCallsTest.kt @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -20,11 +21,10 @@ import org.nypl.simplified.accounts.api.AccountUsername import org.nypl.simplified.accounts.database.api.AccountType import org.nypl.simplified.books.time.tracking.TimeTrackingEntry import org.nypl.simplified.books.time.tracking.TimeTrackingHTTPCalls -import org.nypl.simplified.books.time.tracking.TimeTrackingInfo -import org.nypl.simplified.books.time.tracking.TimeTrackingResponse -import org.nypl.simplified.books.time.tracking.TimeTrackingResponseEntry -import org.nypl.simplified.books.time.tracking.TimeTrackingResponseSummary -import org.nypl.simplified.crashlytics.api.CrashlyticsServiceType +import org.nypl.simplified.books.time.tracking.TimeTrackingRequest +import org.nypl.simplified.books.time.tracking.TimeTrackingServerResponse +import org.nypl.simplified.books.time.tracking.TimeTrackingServerResponseEntry +import org.nypl.simplified.books.time.tracking.TimeTrackingServerResponseSummary import java.net.InetAddress import java.net.URI import java.util.concurrent.TimeUnit @@ -33,8 +33,6 @@ class TimeTrackingHttpCallsTest { private lateinit var httpClient: LSHTTPClientType private lateinit var webServer: MockWebServer - private val crashlytics = Mockito.mock(CrashlyticsServiceType::class.java) - @Mock private val account: AccountType = Mockito.mock(AccountType::class.java) @@ -82,25 +80,31 @@ class TimeTrackingHttpCallsTest { @Test fun testAllEntriesWithSuccess() { - val timeTrackingInfo = Mockito.mock(TimeTrackingInfo::class.java) - Mockito.`when`(timeTrackingInfo.timeTrackingUri) - .thenReturn(this.webServer.url("/timeTracking").toUri()) - - val httpCalls = TimeTrackingHTTPCalls( - objectMapper = ObjectMapper(), - http = httpClient, - crashlytics = crashlytics + val timeTrackingInfo = TimeTrackingRequest( + bookId = "book-id", + libraryId = URI.create("urn:uuid:f8f6b138-02ba-4624-802b-0556278228d5"), + timeTrackingUri = this.webServer.url("/timeTracking").toUri(), + timeEntries = listOf( + TimeTrackingEntry( + id = "id", + duringMinute = "2024-10-16T00:00:00", + secondsPlayed = 60 + ) + ) ) - val responseBody = TimeTrackingResponse( + val httpCalls = + TimeTrackingHTTPCalls(http = httpClient) + + val responseBody = TimeTrackingServerResponse( responses = listOf( - TimeTrackingResponseEntry( + TimeTrackingServerResponseEntry( id = "id", message = "success", status = 201 ) ), - summary = TimeTrackingResponseSummary( + summary = TimeTrackingServerResponseSummary( successes = 1, failures = 0, total = 1 @@ -113,59 +117,57 @@ class TimeTrackingHttpCallsTest { .setBody(ObjectMapper().writeValueAsString(responseBody)) ) - val failedEntries = httpCalls.registerTimeTrackingInfo( - timeTrackingInfo = timeTrackingInfo, + val response = httpCalls.registerTimeTrackingInfo( + request = timeTrackingInfo, account = account ) - assertTrue(failedEntries.isEmpty()) + assertEquals(1, response.responses.size) + assertEquals("success", response.responses[0].message) } @Test fun testSomeEntriesWithSuccess() { - val timeTrackingInfo = Mockito.mock(TimeTrackingInfo::class.java) - Mockito.`when`(timeTrackingInfo.timeEntries) - .thenReturn( - listOf( + val timeTrackingInfo = + TimeTrackingRequest( + bookId = "book-id", + libraryId = URI.create("urn:uuid:f8f6b138-02ba-4624-802b-0556278228d5"), + timeTrackingUri = this.webServer.url("/timeTracking").toUri(), + timeEntries = listOf( TimeTrackingEntry(id = "id", duringMinute = "", 10), TimeTrackingEntry(id = "id2", duringMinute = "", 10), TimeTrackingEntry(id = "id3", duringMinute = "", 10), TimeTrackingEntry(id = "id4", duringMinute = "", 10) ) ) - Mockito.`when`(timeTrackingInfo.timeTrackingUri) - .thenReturn(this.webServer.url("/timeTracking").toUri()) - val httpCalls = TimeTrackingHTTPCalls( - objectMapper = ObjectMapper(), - http = httpClient, - crashlytics = crashlytics - ) + val httpCalls = + TimeTrackingHTTPCalls(http = httpClient) - val responseBody = TimeTrackingResponse( + val responseBody = TimeTrackingServerResponse( responses = listOf( - TimeTrackingResponseEntry( + TimeTrackingServerResponseEntry( id = "id", message = "success", status = 201 ), - TimeTrackingResponseEntry( + TimeTrackingServerResponseEntry( id = "id2", message = "gone", status = 410 ), - TimeTrackingResponseEntry( + TimeTrackingServerResponseEntry( id = "id3", message = "error", status = 400 ), - TimeTrackingResponseEntry( + TimeTrackingServerResponseEntry( id = "id4", message = "another error", status = 400 ) ), - summary = TimeTrackingResponseSummary( + summary = TimeTrackingServerResponseSummary( successes = 1, failures = 3, total = 4 @@ -178,53 +180,51 @@ class TimeTrackingHttpCallsTest { .setBody(ObjectMapper().writeValueAsString(responseBody)) ) - val failedEntries = httpCalls.registerTimeTrackingInfo( - timeTrackingInfo = timeTrackingInfo, + val response = httpCalls.registerTimeTrackingInfo( + request = timeTrackingInfo, account = account ) - assertTrue(failedEntries.size == 2) + assertEquals(3, response.responses.filter { t -> !t.isStatusSuccess() }.size) } @Test fun testNoEntriesWithSuccess() { - val timeTrackingInfo = Mockito.mock(TimeTrackingInfo::class.java) - Mockito.`when`(timeTrackingInfo.timeEntries) - .thenReturn( - listOf( + val timeTrackingInfo = + TimeTrackingRequest( + bookId = "book-id", + libraryId = URI.create("urn:uuid:f8f6b138-02ba-4624-802b-0556278228d5"), + timeTrackingUri = this.webServer.url("/timeTracking").toUri(), + timeEntries = listOf( TimeTrackingEntry(id = "id", duringMinute = "", 10), TimeTrackingEntry(id = "id2", duringMinute = "", 10), TimeTrackingEntry(id = "id3", duringMinute = "", 10) ) ) - Mockito.`when`(timeTrackingInfo.timeTrackingUri) - .thenReturn(this.webServer.url("/timeTracking").toUri()) val httpCalls = TimeTrackingHTTPCalls( - objectMapper = ObjectMapper(), - http = httpClient, - crashlytics = crashlytics + http = httpClient ) - val responseBody = TimeTrackingResponse( + val responseBody = TimeTrackingServerResponse( responses = listOf( - TimeTrackingResponseEntry( + TimeTrackingServerResponseEntry( id = "id", message = "error", status = 400 ), - TimeTrackingResponseEntry( + TimeTrackingServerResponseEntry( id = "id2", message = "another error", status = 400 ), - TimeTrackingResponseEntry( + TimeTrackingServerResponseEntry( id = "id3", message = "and another error", status = 400 ) ), - summary = TimeTrackingResponseSummary( + summary = TimeTrackingServerResponseSummary( successes = 0, failures = 3, total = 3 @@ -237,11 +237,11 @@ class TimeTrackingHttpCallsTest { .setBody(ObjectMapper().writeValueAsString(responseBody)) ) - val failedEntries = httpCalls.registerTimeTrackingInfo( - timeTrackingInfo = timeTrackingInfo, + val response = httpCalls.registerTimeTrackingInfo( + request = timeTrackingInfo, account = account ) - assertTrue(failedEntries.size == 3) + assertEquals(3, response.responses.filter { t -> !t.isStatusSuccess() }.size) } } diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/time_tracking/TimeTrackingMergeTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/time_tracking/TimeTrackingMergeTest.kt new file mode 100644 index 000000000..24f05d8bd --- /dev/null +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/time_tracking/TimeTrackingMergeTest.kt @@ -0,0 +1,286 @@ +package org.nypl.simplified.tests.books.time_tracking + +import com.io7m.jmulticlose.core.CloseableCollection +import com.io7m.jmulticlose.core.CloseableCollectionType +import com.io7m.jmulticlose.core.ClosingResourceFailedException +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID +import org.nypl.simplified.accounts.api.AccountID +import org.nypl.simplified.books.time.tracking.TimeTrackingEntryOutgoing +import org.nypl.simplified.books.time.tracking.TimeTrackingMerge +import org.nypl.simplified.books.time.tracking.TimeTrackingMergeServiceType +import org.nypl.simplified.books.time.tracking.TimeTrackingReceivedSpan +import org.slf4j.LoggerFactory +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.ATOMIC_MOVE +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.nio.file.attribute.FileTime +import java.time.Duration +import java.time.OffsetDateTime +import java.util.UUID +import java.util.concurrent.TimeUnit +import java.util.stream.Collectors + +class TimeTrackingMergeTest { + + private val logger = + LoggerFactory.getLogger(TimeTrackingMergeTest::class.java) + + private val accountID = + AccountID(UUID.randomUUID()) + private val palaceID = + PlayerPalaceID("cbd92367-f3e1-4310-a767-a07058271c2b") + private val palaceIDWrong = + PlayerPalaceID("e1cc010f-e88d-4c16-a12e-ab7b9f5c2065") + private val targetURI = + URI.create("https://www.example.com") + + @Volatile + private lateinit var timeNow: OffsetDateTime + private lateinit var clock: () -> OffsetDateTime + private lateinit var resources: CloseableCollectionType + private lateinit var inboxDirectory: Path + private lateinit var outboxDirectory: Path + private lateinit var merge: TimeTrackingMergeServiceType + + @BeforeEach + fun setup( + @TempDir outboxDirectory: Path, + @TempDir inboxDirectory: Path + ) { + this.timeNow = OffsetDateTime.now() + this.clock = { this.timeNow } + + this.resources = CloseableCollection.create() + this.outboxDirectory = outboxDirectory + this.inboxDirectory = inboxDirectory + + this.merge = + TimeTrackingMerge.create( + outputDirectory = outboxDirectory, + inputDirectory = inboxDirectory, + clock = this.clock, + frequency = Duration.ofMillis(100L) + ) + + this.resources.add(AutoCloseable { this.merge.close() }) + } + + @AfterEach + fun tearDown() { + this.resources.close() + } + + @Test + fun testMergeOneMinuteBoundary() { + val spans = listOf( + TimeTrackingReceivedSpan( + id = UUID.randomUUID(), + accountID = this.accountID, + bookID = this.palaceID, + libraryID = URI.create("urn:uuid:f19fc9b0-6259-4e0a-a76b-2246543e8b6b"), + timeStarted = OffsetDateTime.parse("2024-10-15T00:00:30Z"), + timeEnded = OffsetDateTime.parse("2024-10-15T00:01:30Z"), + targetURI = this.targetURI + ) + ) + + /* + * An entry that spans a minute boundary produces two time tracking entries. + */ + + val totalExpected = 60 + + val entries = + TimeTrackingMerge.mergeEntries(spans) + val totalReceived = + entries.sumOf { e -> e.timeEntry.secondsPlayed } + + assertEquals(totalExpected, totalReceived) + assertEquals(2, entries.size) + } + + @Test + fun testMergeOverlapping() { + val spans = listOf( + TimeTrackingReceivedSpan( + id = UUID.randomUUID(), + accountID = this.accountID, + bookID = this.palaceID, + libraryID = URI.create("urn:uuid:f19fc9b0-6259-4e0a-a76b-2246543e8b6b"), + timeStarted = OffsetDateTime.parse("2024-10-15T00:00:00Z"), + timeEnded = OffsetDateTime.parse("2024-10-15T00:00:45Z"), + targetURI = this.targetURI + ), + TimeTrackingReceivedSpan( + id = UUID.randomUUID(), + accountID = this.accountID, + bookID = this.palaceID, + libraryID = URI.create("urn:uuid:f19fc9b0-6259-4e0a-a76b-2246543e8b6b"), + timeStarted = OffsetDateTime.parse("2024-10-15T00:00:00Z"), + timeEnded = OffsetDateTime.parse("2024-10-15T00:00:45Z"), + targetURI = this.targetURI + ) + ) + + /* + * A silly answer to a silly question: We're claiming that we've listened to a book + * twice in the exact same time period. The results simply sum and are clamped to sixty + * seconds for the one minute they fall within. + */ + + val totalExpected = 60 + + val entries = + TimeTrackingMerge.mergeEntries(spans) + val totalReceived = + entries.sumOf { e -> e.timeEntry.secondsPlayed } + + assertEquals(totalExpected, totalReceived) + assertEquals(1, entries.size) + } + + @Test + fun testMerge0() { + val spans = listOf( + TimeTrackingReceivedSpan( + id = UUID.randomUUID(), + accountID = this.accountID, + bookID = this.palaceID, + libraryID = URI.create("urn:uuid:f19fc9b0-6259-4e0a-a76b-2246543e8b6b"), + timeStarted = OffsetDateTime.parse("2024-10-15T00:00:20Z"), + timeEnded = OffsetDateTime.parse("2024-10-15T00:00:30Z"), + targetURI = this.targetURI + ), + TimeTrackingReceivedSpan( + id = UUID.randomUUID(), + accountID = this.accountID, + bookID = this.palaceID, + libraryID = URI.create("urn:uuid:f19fc9b0-6259-4e0a-a76b-2246543e8b6b"), + timeStarted = OffsetDateTime.parse("2024-10-15T00:00:31Z"), + timeEnded = OffsetDateTime.parse("2024-10-15T00:00:50Z"), + targetURI = this.targetURI + ), + TimeTrackingReceivedSpan( + id = UUID.randomUUID(), + accountID = this.accountID, + bookID = this.palaceID, + libraryID = URI.create("urn:uuid:f19fc9b0-6259-4e0a-a76b-2246543e8b6b"), + timeStarted = OffsetDateTime.parse("2024-10-15T00:00:51Z"), + timeEnded = OffsetDateTime.parse("2024-10-15T00:01:40Z"), + targetURI = this.targetURI + ) + ) + + /* + * The entries sum to the original length of the spans. + */ + + val totalExpected = spans.sumOf { span -> + Duration.between(span.timeStarted, span.timeEnded).toMillis() / 1000 + }.toInt() + + val entries = + TimeTrackingMerge.mergeEntries(spans) + val totalReceived = + entries.sumOf { e -> e.timeEntry.secondsPlayed } + + assertEquals(totalExpected, totalReceived) + assertEquals(2, entries.size) + } + + @Test + fun testMergeEntries() { + val spans = listOf( + TimeTrackingReceivedSpan( + id = UUID.randomUUID(), + accountID = this.accountID, + bookID = this.palaceID, + libraryID = URI.create("urn:uuid:f19fc9b0-6259-4e0a-a76b-2246543e8b6b"), + timeStarted = OffsetDateTime.parse("2024-10-15T00:00:20Z"), + timeEnded = OffsetDateTime.parse("2024-10-15T00:00:30Z"), + targetURI = this.targetURI + ), + TimeTrackingReceivedSpan( + id = UUID.randomUUID(), + accountID = this.accountID, + bookID = this.palaceID, + libraryID = URI.create("urn:uuid:f19fc9b0-6259-4e0a-a76b-2246543e8b6b"), + timeStarted = OffsetDateTime.parse("2024-10-15T00:00:31Z"), + timeEnded = OffsetDateTime.parse("2024-10-15T00:00:50Z"), + targetURI = this.targetURI + ), + TimeTrackingReceivedSpan( + id = UUID.randomUUID(), + accountID = this.accountID, + bookID = this.palaceID, + libraryID = URI.create("urn:uuid:f19fc9b0-6259-4e0a-a76b-2246543e8b6b"), + timeStarted = OffsetDateTime.parse("2024-10-15T00:00:51Z"), + timeEnded = OffsetDateTime.parse("2024-10-15T00:01:40Z"), + targetURI = this.targetURI + ) + ) + + val totalExpected = spans.sumOf { span -> + Duration.between(span.timeStarted, span.timeEnded).toMillis() / 1000 + }.toInt() + + spans.forEach { s -> + val fileTmp = + this.inboxDirectory.resolve("${s.id}.ttspan.tmp") + val file = + this.inboxDirectory.resolve("${s.id}.ttspan") + + Files.write(fileTmp, s.toBytes()) + Files.move(fileTmp, file, ATOMIC_MOVE, REPLACE_EXISTING) + Files.setLastModifiedTime( + file, + FileTime.from(this.timeNow.toInstant()) + ) + } + + /* + * Wind the clock forward two minutes so that the previously written spans are now eligible + * for processing. + */ + + this.timeNow = this.timeNow.plusMinutes(2L) + this.merge.awaitTick(1L, TimeUnit.SECONDS) + this.merge.awaitTick(1L, TimeUnit.SECONDS) + + val inboxNow = Files.list(this.inboxDirectory).collect(Collectors.toUnmodifiableList()) + this.logger.debug("Inbox now: {}", inboxNow) + if (inboxNow.isNotEmpty()) { + throw IllegalStateException("Files not processed!") + } + + val outboxNow = Files.list(this.outboxDirectory).collect(Collectors.toUnmodifiableList()) + this.logger.debug("Outbox now: {}", inboxNow) + + val e0 = + TimeTrackingEntryOutgoing.ofFile(outboxNow.get(0)) + val e1 = + TimeTrackingEntryOutgoing.ofFile(outboxNow.get(1)) + + assertEquals( + totalExpected, + e0.timeEntry.secondsPlayed + e1.timeEntry.secondsPlayed + ) + assertNotEquals( + e0.timeEntry.id, + e1.timeEntry.id + ) + assertNotEquals( + e0.timeEntry.duringMinute, + e1.timeEntry.duringMinute + ) + assertEquals(2, outboxNow.size) + } +} diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/time_tracking/TimeTrackingSenderTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/time_tracking/TimeTrackingSenderTest.kt new file mode 100644 index 000000000..e846ed1c8 --- /dev/null +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/time_tracking/TimeTrackingSenderTest.kt @@ -0,0 +1,548 @@ +package org.nypl.simplified.tests.books.time_tracking + +import com.io7m.jmulticlose.core.CloseableCollection +import com.io7m.jmulticlose.core.CloseableCollectionType +import com.io7m.jmulticlose.core.ClosingResourceFailedException +import io.reactivex.subjects.PublishSubject +import okio.IOException +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import org.junit.jupiter.api.io.TempDir +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID +import org.librarysimplified.audiobook.time_tracking.PlayerTimeTracked +import org.mockito.Mockito +import org.mockito.internal.verification.Times +import org.mockito.kotlin.any +import org.nypl.simplified.accounts.api.AccountID +import org.nypl.simplified.accounts.api.AccountProviderType +import org.nypl.simplified.accounts.database.api.AccountType +import org.nypl.simplified.books.time.tracking.TimeTrackingEntry +import org.nypl.simplified.books.time.tracking.TimeTrackingEntryOutgoing +import org.nypl.simplified.books.time.tracking.TimeTrackingHTTPCallsType +import org.nypl.simplified.books.time.tracking.TimeTrackingRequest +import org.nypl.simplified.books.time.tracking.TimeTrackingSender +import org.nypl.simplified.books.time.tracking.TimeTrackingSenderServiceType +import org.nypl.simplified.books.time.tracking.TimeTrackingServerResponse +import org.nypl.simplified.books.time.tracking.TimeTrackingServerResponseEntry +import org.nypl.simplified.books.time.tracking.TimeTrackingServerResponseSummary +import org.nypl.simplified.profiles.api.ProfileType +import org.nypl.simplified.profiles.controller.api.ProfilesControllerType +import org.slf4j.LoggerFactory +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption.CREATE +import java.nio.file.StandardOpenOption.TRUNCATE_EXISTING +import java.nio.file.StandardOpenOption.WRITE +import java.time.Duration +import java.util.UUID +import java.util.concurrent.TimeUnit +import java.util.stream.Collectors + +class TimeTrackingSenderTest { + + private val logger = + LoggerFactory.getLogger(TimeTrackingSenderTest::class.java) + + private val accountID = + AccountID(UUID.randomUUID()) + private val palaceID = + PlayerPalaceID("cbd92367-f3e1-4310-a767-a07058271c2b") + private val palaceIDWrong = + PlayerPalaceID("e1cc010f-e88d-4c16-a12e-ab7b9f5c2065") + + private lateinit var profiles: ProfilesControllerType + private lateinit var resources: CloseableCollectionType + private lateinit var sender: TimeTrackingSenderServiceType + private lateinit var inboxDirectory: Path + private lateinit var debugDirectory: Path + private lateinit var timeSegments: PublishSubject + private lateinit var accountRef: AccountType + private lateinit var profileRef: ProfileType + private lateinit var accountProviderRef: AccountProviderType + private lateinit var httpCalls: TimeTrackingHTTPCallsType + + @BeforeEach + fun setup( + @TempDir debugDirectory: Path, + @TempDir inboxDirectory: Path + ) { + this.resources = CloseableCollection.create() + this.debugDirectory = debugDirectory + this.inboxDirectory = inboxDirectory + + this.profiles = + Mockito.mock(ProfilesControllerType::class.java) + this.accountRef = + Mockito.mock(AccountType::class.java) + this.profileRef = + Mockito.mock(ProfileType::class.java) + this.accountProviderRef = + Mockito.mock(AccountProviderType::class.java) + + Mockito.`when`(this.profiles.profileCurrent()) + .thenReturn(this.profileRef) + Mockito.`when`(this.profileRef.account(any())) + .thenReturn(this.accountRef) + Mockito.`when`(this.accountRef.provider) + .thenReturn(this.accountProviderRef) + Mockito.`when`(this.accountProviderRef.id) + .thenReturn(URI.create("urn:uuid:d36a27ab-acd4-4e49-b2cc-19086c780cfb")) + + this.httpCalls = + Mockito.mock(TimeTrackingHTTPCallsType::class.java) + + this.timeSegments = + PublishSubject.create() + + this.resources.add(AutoCloseable { this.timeSegments.onComplete() }) + + this.sender = + TimeTrackingSender.create( + profiles = this.profiles, + httpCalls = this.httpCalls, + frequency = Duration.ofMillis(100L), + debugDirectory = debugDirectory, + inputDirectory = inboxDirectory + ) + + this.resources.add(AutoCloseable { this.sender.close() }) + } + + @AfterEach + fun tearDown() { + this.resources.close() + } + + @Test + @Timeout(value = 5L, unit = TimeUnit.SECONDS) + fun testSenderWritesNothing() { + this.sender.awaitWrite(1L, TimeUnit.SECONDS) + this.sender.awaitWrite(1L, TimeUnit.SECONDS) + this.sender.awaitWrite(1L, TimeUnit.SECONDS) + + Mockito.verifyNoInteractions(this.httpCalls) + } + + @Test + @Timeout(value = 5L, unit = TimeUnit.SECONDS) + fun testSenderUnparseableFile() { + Files.write(this.inboxDirectory.resolve("x.tteo"), "Not valid!".toByteArray()) + + this.sender.awaitWrite(1L, TimeUnit.SECONDS) + + Mockito.verifyNoInteractions(this.httpCalls) + + assertEquals( + listOf(), + Files.list(this.debugDirectory).collect(Collectors.toUnmodifiableList()) + ) + } + + /** + * An entry is sent and then removed on success. + */ + + @Test + @Timeout(value = 5L, unit = TimeUnit.SECONDS) + fun testSenderEntryWritten0() { + val entry = + TimeTrackingEntryOutgoing( + accountID = this.accountID, + libraryID = this.accountProviderRef.id, + bookID = this.palaceID, + targetURI = URI.create("https://www.example.com"), + timeEntry = TimeTrackingEntry( + id = "01JAD2H8Y8DY3K0WZVBXZH3MBM", + duringMinute = "2024-10-17T00:00:00", + secondsPlayed = 60 + ) + ) + + Mockito.`when`(this.httpCalls.registerTimeTrackingInfo(any(), any())) + .thenReturn(TimeTrackingServerResponse( + responses = listOf( + TimeTrackingServerResponseEntry( + id = "01JAD2H8Y8DY3K0WZVBXZH3MBM", + message = "OK", + status = 200 + ) + ), + summary = TimeTrackingServerResponseSummary( + failures = 0, + successes = 1, + total = 1 + ) + )) + + val file = + this.inboxDirectory.resolve("01JAD2H8Y8DY3K0WZVBXZH3MBM.tteo") + val fileTmp = + this.inboxDirectory.resolve("01JAD2H8Y8DY3K0WZVBXZH3MBM.tteo.tmp") + + Files.newOutputStream(fileTmp, WRITE, CREATE, TRUNCATE_EXISTING).use { s -> + entry.toProperties().store(s, "") + s.flush() + } + Files.move( + fileTmp, + file, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + + this.sender.awaitWrite(1L, TimeUnit.SECONDS) + + Mockito.verify(this.httpCalls, Times(1)) + .registerTimeTrackingInfo( + request = TimeTrackingRequest( + bookId = this.palaceID.value, + libraryId = this.accountProviderRef.id, + timeTrackingUri = URI.create("https://www.example.com"), + timeEntries = listOf(entry.timeEntry) + ), + account = this.accountRef + ) + + assertNotEquals( + listOf(), + Files.list(this.debugDirectory).collect(Collectors.toUnmodifiableList()) + ) + assertFalse(Files.exists(file), "File must have been deleted.") + } + + /** + * An entry is sent and then removed on success. + */ + + @Test + @Timeout(value = 5L, unit = TimeUnit.SECONDS) + fun testSenderEntryWritten1() { + val entry = + TimeTrackingEntryOutgoing( + accountID = this.accountID, + libraryID = this.accountProviderRef.id, + bookID = this.palaceID, + targetURI = URI.create("https://www.example.com"), + timeEntry = TimeTrackingEntry( + id = "01JAD2H8Y8DY3K0WZVBXZH3MBM", + duringMinute = "2024-10-17T00:00:00", + secondsPlayed = 60 + ) + ) + + Mockito.`when`(this.httpCalls.registerTimeTrackingInfo(any(), any())) + .thenReturn(TimeTrackingServerResponse( + responses = listOf(), + summary = TimeTrackingServerResponseSummary( + failures = 0, + successes = 1, + total = 1 + ) + )) + + val file = + this.inboxDirectory.resolve("01JAD2H8Y8DY3K0WZVBXZH3MBM.tteo") + val fileTmp = + this.inboxDirectory.resolve("01JAD2H8Y8DY3K0WZVBXZH3MBM.tteo.tmp") + + Files.newOutputStream(fileTmp, WRITE, CREATE, TRUNCATE_EXISTING).use { s -> + entry.toProperties().store(s, "") + s.flush() + } + Files.move( + fileTmp, + file, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + + this.sender.awaitWrite(1L, TimeUnit.SECONDS) + + Mockito.verify(this.httpCalls, Times(1)) + .registerTimeTrackingInfo( + request = TimeTrackingRequest( + bookId = this.palaceID.value, + libraryId = this.accountProviderRef.id, + timeTrackingUri = URI.create("https://www.example.com"), + timeEntries = listOf(entry.timeEntry) + ), + account = this.accountRef + ) + + assertNotEquals( + listOf(), + Files.list(this.debugDirectory).collect(Collectors.toUnmodifiableList()) + ) + assertFalse(Files.exists(file), "File must have been deleted.") + } + + /** + * An entry is retried on failure. + */ + + @Test + @Timeout(value = 5L, unit = TimeUnit.SECONDS) + fun testSenderEntryRetries0() { + val entry = + TimeTrackingEntryOutgoing( + accountID = this.accountID, + libraryID = this.accountProviderRef.id, + bookID = this.palaceID, + targetURI = URI.create("https://www.example.com"), + timeEntry = TimeTrackingEntry( + id = "01JAD2H8Y8DY3K0WZVBXZH3MBM", + duringMinute = "2024-10-17T00:00:00", + secondsPlayed = 60 + ) + ) + + /* + * The server returns an error twice, and then returns success on the third attempt. + */ + + Mockito.`when`(this.httpCalls.registerTimeTrackingInfo(any(), any())) + .thenReturn(TimeTrackingServerResponse( + responses = listOf( + TimeTrackingServerResponseEntry( + id = "01JAD2H8Y8DY3K0WZVBXZH3MBM", + message = "Error!", + status = 400 + ) + ), + summary = TimeTrackingServerResponseSummary( + failures = 1, + successes = 0, + total = 1 + ) + )) + .thenReturn(TimeTrackingServerResponse( + responses = listOf( + TimeTrackingServerResponseEntry( + id = "01JAD2H8Y8DY3K0WZVBXZH3MBM", + message = "Error!", + status = 400 + ) + ), + summary = TimeTrackingServerResponseSummary( + failures = 1, + successes = 0, + total = 1 + ) + )) + .thenReturn(TimeTrackingServerResponse( + responses = listOf(), + summary = TimeTrackingServerResponseSummary( + failures = 0, + successes = 1, + total = 1 + ) + )) + + val file = + this.inboxDirectory.resolve("01JAD2H8Y8DY3K0WZVBXZH3MBM.tteo") + val fileTmp = + this.inboxDirectory.resolve("01JAD2H8Y8DY3K0WZVBXZH3MBM.tteo.tmp") + + Files.newOutputStream(fileTmp, WRITE, CREATE, TRUNCATE_EXISTING).use { s -> + entry.toProperties().store(s, "") + s.flush() + } + Files.move( + fileTmp, + file, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + + this.sender.awaitWrite(1L, TimeUnit.SECONDS) + this.sender.awaitWrite(1L, TimeUnit.SECONDS) + this.sender.awaitWrite(1L, TimeUnit.SECONDS) + + Mockito.verify(this.httpCalls, Times(3)) + .registerTimeTrackingInfo( + request = TimeTrackingRequest( + bookId = this.palaceID.value, + libraryId = this.accountProviderRef.id, + timeTrackingUri = URI.create("https://www.example.com"), + timeEntries = listOf(entry.timeEntry) + ), + account = this.accountRef + ) + + assertNotEquals( + listOf(), + Files.list(this.debugDirectory).collect(Collectors.toUnmodifiableList()) + ) + assertFalse(Files.exists(file), "File must have been deleted.") + } + + /** + * An entry is retried on failure. + */ + + @Test + @Timeout(value = 5L, unit = TimeUnit.SECONDS) + fun testSenderEntryRetries1() { + val entry = + TimeTrackingEntryOutgoing( + accountID = this.accountID, + libraryID = this.accountProviderRef.id, + bookID = this.palaceID, + targetURI = URI.create("https://www.example.com"), + timeEntry = TimeTrackingEntry( + id = "01JAD2H8Y8DY3K0WZVBXZH3MBM", + duringMinute = "2024-10-17T00:00:00", + secondsPlayed = 60 + ) + ) + + /* + * The server returns an error twice, and then returns success on the third attempt. + */ + + Mockito.`when`(this.httpCalls.registerTimeTrackingInfo(any(), any())) + .thenReturn(TimeTrackingServerResponse( + responses = listOf(), + summary = TimeTrackingServerResponseSummary( + failures = 1, + successes = 0, + total = 1 + ) + )) + .thenReturn(TimeTrackingServerResponse( + responses = listOf(), + summary = TimeTrackingServerResponseSummary( + failures = 1, + successes = 0, + total = 1 + ) + )) + .thenReturn(TimeTrackingServerResponse( + responses = listOf(), + summary = TimeTrackingServerResponseSummary( + failures = 0, + successes = 1, + total = 1 + ) + )) + + val file = + this.inboxDirectory.resolve("01JAD2H8Y8DY3K0WZVBXZH3MBM.tteo") + val fileTmp = + this.inboxDirectory.resolve("01JAD2H8Y8DY3K0WZVBXZH3MBM.tteo.tmp") + + Files.newOutputStream(fileTmp, WRITE, CREATE, TRUNCATE_EXISTING).use { s -> + entry.toProperties().store(s, "") + s.flush() + } + Files.move( + fileTmp, + file, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + + this.sender.awaitWrite(1L, TimeUnit.SECONDS) + this.sender.awaitWrite(1L, TimeUnit.SECONDS) + this.sender.awaitWrite(1L, TimeUnit.SECONDS) + + Mockito.verify(this.httpCalls, Times(3)) + .registerTimeTrackingInfo( + request = TimeTrackingRequest( + bookId = this.palaceID.value, + libraryId = this.accountProviderRef.id, + timeTrackingUri = URI.create("https://www.example.com"), + timeEntries = listOf(entry.timeEntry) + ), + account = this.accountRef + ) + + assertNotEquals( + listOf(), + Files.list(this.debugDirectory).collect(Collectors.toUnmodifiableList()) + ) + assertFalse(Files.exists(file), "File must have been deleted.") + } + + /** + * An entry is retried on failure. + */ + + @Test + @Timeout(value = 5L, unit = TimeUnit.SECONDS) + fun testSenderEntryRetries2() { + val entry = + TimeTrackingEntryOutgoing( + accountID = this.accountID, + libraryID = this.accountProviderRef.id, + bookID = this.palaceID, + targetURI = URI.create("https://www.example.com"), + timeEntry = TimeTrackingEntry( + id = "01JAD2H8Y8DY3K0WZVBXZH3MBM", + duringMinute = "2024-10-17T00:00:00", + secondsPlayed = 60 + ) + ) + + /* + * The HTTP call raises twice, and then returns success on the third attempt. + */ + + Mockito.`when`(this.httpCalls.registerTimeTrackingInfo(any(), any())) + .thenThrow(java.io.IOException("Ouch!")) + .thenThrow(java.io.IOException("Ouch!")) + .thenReturn(TimeTrackingServerResponse( + responses = listOf(), + summary = TimeTrackingServerResponseSummary( + failures = 0, + successes = 1, + total = 1 + ) + )) + + val file = + this.inboxDirectory.resolve("01JAD2H8Y8DY3K0WZVBXZH3MBM.tteo") + val fileTmp = + this.inboxDirectory.resolve("01JAD2H8Y8DY3K0WZVBXZH3MBM.tteo.tmp") + + Files.newOutputStream(fileTmp, WRITE, CREATE, TRUNCATE_EXISTING).use { s -> + entry.toProperties().store(s, "") + s.flush() + } + Files.move( + fileTmp, + file, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + + this.sender.awaitWrite(1L, TimeUnit.SECONDS) + this.sender.awaitWrite(1L, TimeUnit.SECONDS) + this.sender.awaitWrite(1L, TimeUnit.SECONDS) + + Mockito.verify(this.httpCalls, Times(3)) + .registerTimeTrackingInfo( + request = TimeTrackingRequest( + bookId = this.palaceID.value, + libraryId = this.accountProviderRef.id, + timeTrackingUri = URI.create("https://www.example.com"), + timeEntries = listOf(entry.timeEntry) + ), + account = this.accountRef + ) + + assertNotEquals( + listOf(), + Files.list(this.debugDirectory).collect(Collectors.toUnmodifiableList()) + ) + assertFalse(Files.exists(file), "File must have been deleted.") + } +} diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/http/refresh_token/time_tracking/TimeTrackingRefreshTokenTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/http/refresh_token/time_tracking/TimeTrackingRefreshTokenTest.kt index 59346c3d4..43a44a823 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/http/refresh_token/time_tracking/TimeTrackingRefreshTokenTest.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/http/refresh_token/time_tracking/TimeTrackingRefreshTokenTest.kt @@ -27,11 +27,12 @@ import org.nypl.simplified.accounts.api.AccountPassword import org.nypl.simplified.accounts.api.AccountUsername import org.nypl.simplified.books.book_registry.BookRegistry import org.nypl.simplified.books.book_registry.BookRegistryType +import org.nypl.simplified.books.time.tracking.TimeTrackingEntry import org.nypl.simplified.books.time.tracking.TimeTrackingHTTPCalls -import org.nypl.simplified.books.time.tracking.TimeTrackingInfo -import org.nypl.simplified.books.time.tracking.TimeTrackingResponse -import org.nypl.simplified.books.time.tracking.TimeTrackingResponseEntry -import org.nypl.simplified.books.time.tracking.TimeTrackingResponseSummary +import org.nypl.simplified.books.time.tracking.TimeTrackingRequest +import org.nypl.simplified.books.time.tracking.TimeTrackingServerResponse +import org.nypl.simplified.books.time.tracking.TimeTrackingServerResponseEntry +import org.nypl.simplified.books.time.tracking.TimeTrackingServerResponseSummary import org.nypl.simplified.crashlytics.api.CrashlyticsServiceType import org.nypl.simplified.tests.mocking.MockAccount import java.net.URI @@ -44,8 +45,6 @@ class TimeTrackingRefreshTokenTest { private lateinit var httpClient: LSHTTPClientType private lateinit var webServer: MockWebServer - private val crashlytics = Mockito.mock(CrashlyticsServiceType::class.java) - @BeforeEach fun testSetup() { this.accountID = @@ -107,25 +106,32 @@ class TimeTrackingRefreshTokenTest { @Test fun testSendEntriesUpdateToken() { - val timeTrackingInfo = Mockito.mock(TimeTrackingInfo::class.java) - Mockito.`when`(timeTrackingInfo.timeTrackingUri) - .thenReturn(this.webServer.url("/timeTracking").toUri()) + val timeTrackingInfo = TimeTrackingRequest( + bookId = "book-id", + libraryId = URI.create("urn:uuid:f8f6b138-02ba-4624-802b-0556278228d5"), + timeTrackingUri = this.webServer.url("/timeTracking").toUri(), + timeEntries = listOf( + TimeTrackingEntry( + id = "id", + duringMinute = "2024-10-16T00:00:00", + secondsPlayed = 60 + ) + ) + ) val httpCalls = TimeTrackingHTTPCalls( - objectMapper = ObjectMapper(), - http = httpClient, - crashlytics = crashlytics + http = httpClient ) - val responseBody = TimeTrackingResponse( + val responseBody = TimeTrackingServerResponse( responses = listOf( - TimeTrackingResponseEntry( + TimeTrackingServerResponseEntry( id = "id", message = "success", status = 201 ) ), - summary = TimeTrackingResponseSummary( + summary = TimeTrackingServerResponseSummary( successes = 1, failures = 0, total = 1 @@ -140,11 +146,10 @@ class TimeTrackingRefreshTokenTest { ) val failedEntries = httpCalls.registerTimeTrackingInfo( - timeTrackingInfo = timeTrackingInfo, + request = timeTrackingInfo, account = account ) - Assertions.assertTrue(failedEntries.isEmpty()) Assertions.assertEquals( "ghij", (account.loginState.credentials as AccountAuthenticationCredentials.BasicToken) diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccountProviderRegistry.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccountProviderRegistry.kt index 53abb6d0d..3d5bb362e 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccountProviderRegistry.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockAccountProviderRegistry.kt @@ -100,7 +100,7 @@ class MockAccountProviderRegistry( if (this.resolveNext.peek() != null) { this.logger.debug("took provider from queue") val queued = this.resolveNext.poll() - val copy = AccountProvider.copy(queued).copy(id = description.id) + val copy = AccountProvider.copy(queued!!).copy(id = description.id) this.resolvedProviders[copy.id] = copy return taskRecorder.finishSuccess(copy) } diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockLCPService.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockLCPService.kt index 3d2a1d2db..7b3f67385 100644 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockLCPService.kt +++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockLCPService.kt @@ -11,6 +11,7 @@ import org.readium.r2.lcp.license.model.LicenseDocument import org.readium.r2.shared.publication.protection.ContentProtection import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.asset.Asset +import org.readium.r2.shared.util.asset.ContainerAsset import java.io.File class MockLCPService( @@ -76,6 +77,12 @@ class MockLCPService( return Try.failure(LcpError.LicenseProfileNotSupported) } + override suspend fun retrieveLicenseDocument( + asset: ContainerAsset + ): Try { + return Try.failure(LcpError.LicenseProfileNotSupported) + } + @Deprecated( "Use an AssetSniffer and check the conformance of the returned format to LcpSpecification", level = DeprecationLevel.ERROR diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/time_tracking/TimeTrackingJSONTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/time_tracking/TimeTrackingJSONTest.kt deleted file mode 100644 index 320f830a6..000000000 --- a/simplified-tests/src/test/java/org/nypl/simplified/tests/time_tracking/TimeTrackingJSONTest.kt +++ /dev/null @@ -1,194 +0,0 @@ -package org.nypl.simplified.tests.time_tracking - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.node.ObjectNode -import org.joda.time.DateTimeZone -import org.joda.time.format.DateTimeFormatter -import org.joda.time.format.ISODateTimeFormat -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.nypl.simplified.books.time.tracking.TimeTrackingEntry -import org.nypl.simplified.books.time.tracking.TimeTrackingInfo -import org.nypl.simplified.books.time.tracking.TimeTrackingJSON -import org.nypl.simplified.json.core.JSONParserUtilities -import java.net.URI - -class TimeTrackingJSONTest { - - private lateinit var objectMapper: ObjectMapper - private lateinit var formatter: DateTimeFormatter - - @BeforeEach - fun testSetup() { - this.objectMapper = ObjectMapper() - this.formatter = ISODateTimeFormat.dateTime().withZoneUTC() - } - - @AfterEach - fun tearDown() { - DateTimeZone.setDefault(DateTimeZone.getDefault()) - } - - @Test - fun testTimeTrackingInfoFromBytes() { - val timeTrackingInfoBytes = "{" + - "\"accountId\":\"accountId\"," + - "\"bookId\":\"bookId\"," + - "\"libraryId\":\"libraryId\"," + - "\"uri\":\"https://projectpalace.io/timeTracking\"," + - "\"timeEntries\": [" + - "{\"id\":\"id\",\"duringMinute\":\"2023-08-08T12:50Z\",\"secondsPlayed\":50}" + - "]" + - "}" - - val timeTrackingInfo = TimeTrackingJSON.convertBytesToTimeTrackingInfo( - bytes = timeTrackingInfoBytes.toByteArray() - ) - - assertNotNull(timeTrackingInfo) - assertEquals(timeTrackingInfo?.accountId, "accountId") - assertEquals(timeTrackingInfo?.bookId, "bookId") - assertEquals(timeTrackingInfo?.libraryId, "libraryId") - assertEquals(timeTrackingInfo?.timeTrackingUri, URI.create("https://projectpalace.io/timeTracking")) - assertEquals(timeTrackingInfo?.timeEntries.orEmpty().size, 1) - - val entry = timeTrackingInfo?.timeEntries.orEmpty().first() - assertEquals(entry.id, "id") - assertEquals(entry.duringMinute, "2023-08-08T12:50Z") - assertEquals(entry.secondsPlayed, 50) - } - - @Test - fun testTimeTrackingInfoFromBytesNoEntries() { - val timeTrackingInfoBytes = "{" + - "\"accountId\":\"accountId\"," + - "\"bookId\":\"bookId\"," + - "\"libraryId\":\"libraryId\"," + - "\"uri\":\"https://projectpalace.io/timeTracking\"," + - "\"timeEntries\": []" + - "}" - - val timeTrackingInfo = TimeTrackingJSON.convertBytesToTimeTrackingInfo( - bytes = timeTrackingInfoBytes.toByteArray() - ) - - assertNotNull(timeTrackingInfo) - assertEquals(timeTrackingInfo?.accountId, "accountId") - assertEquals(timeTrackingInfo?.bookId, "bookId") - assertEquals(timeTrackingInfo?.libraryId, "libraryId") - assertEquals(timeTrackingInfo?.timeTrackingUri, URI.create("https://projectpalace.io/timeTracking")) - assertEquals(timeTrackingInfo?.timeEntries, emptyList()) - } - - @Test - fun testInvalidTimeTrackingInfoFromBytes() { - val timeTrackingInfoBytes = "{" + - "\"bookId\":\"bookId\"" + - "}" - - val timeTrackingInfo = TimeTrackingJSON.convertBytesToTimeTrackingInfo( - bytes = timeTrackingInfoBytes.toByteArray() - ) - - assertNull(timeTrackingInfo) - } - - @Test - fun testConvertTimeTrackingInfoToLocalJSON() { - val timeTrackingInfo = TimeTrackingInfo( - accountId = "accountId", - bookId = "bookId", - libraryId = "libraryId", - timeTrackingUri = URI.create("https://palaceproject.io/timeTracking"), - timeEntries = listOf( - TimeTrackingEntry( - id = "id", - duringMinute = "2023-08-08T10:50Z", - secondsPlayed = 40 - ) - ) - ) - - val objectNode = TimeTrackingJSON.convertTimeTrackingToLocalJSON( - objectMapper = objectMapper, - timeTrackingInfo = timeTrackingInfo - ) - - assertNotNull(objectNode) - assertEquals(objectNode.get("accountId").asText(), timeTrackingInfo.accountId) - assertEquals(objectNode.get("bookId").asText(), timeTrackingInfo.bookId) - assertEquals(objectNode.get("libraryId").asText(), timeTrackingInfo.libraryId) - assertEquals(objectNode.get("uri").asText(), timeTrackingInfo.timeTrackingUri.toString()) - - val entriesArray = JSONParserUtilities.getArray(objectNode, "timeEntries") - assertEquals(entriesArray.size(), timeTrackingInfo.timeEntries.size) - - val entry = entriesArray.first() - val timeTrackingEntry = TimeTrackingEntry( - id = entry.get("id").asText(), - duringMinute = entry.get("duringMinute").asText(), - secondsPlayed = entry.get("secondsPlayed").asInt() - ) - assertEquals(timeTrackingEntry, timeTrackingInfo.timeEntries.first()) - } - - @Test - fun testConvertTimeTrackingInfoToBytesForServerRequest() { - val timeTrackingInfo = TimeTrackingInfo( - accountId = "accountId", - bookId = "bookId", - libraryId = "libraryId", - timeTrackingUri = URI.create("https://palaceproject.io/timeTracking"), - timeEntries = listOf( - TimeTrackingEntry( - id = "id", - duringMinute = "2023-08-08T10:50Z", - secondsPlayed = 40 - ) - ) - ) - - val bytes = TimeTrackingJSON.convertTimeTrackingInfoToBytes( - objectMapper = objectMapper, - timeTrackingInfo = timeTrackingInfo - ) - - val objectNode = objectMapper.readTree(bytes) - - // converting to bytes should "clear" the accountId and URI fields as they are not needed on the server - assertNotNull(objectNode) - assertFalse(objectNode.has("accountId")) - assertFalse(objectNode.has("uri")) - } - - @Test - fun testConvertServerResponse() { - val responseString = "{" + - "\"responses\":[" + - "{\"id\":\"id\",\"message\":\"Success!\",\"status\":201}" + - "]," + - "\"summary\":{" + - "\"successes\":1," + - "\"failures\":0," + - "\"total\":1" + - "}" + - "}" - - val objectNode = objectMapper.readTree(responseString) as ObjectNode - val response = TimeTrackingJSON.convertServerResponseToTimeTrackingResponse(objectNode) - - assertNotNull(response) - assertEquals(response?.summary?.successes, 1) - assertEquals(response?.summary?.failures, 0) - assertEquals(response?.summary?.total, 1) - assertEquals(response?.responses.orEmpty().size, 1) - assertEquals(response?.responses?.first()?.id, "id") - assertEquals(response?.responses?.first()?.message, "Success!") - assertEquals(response?.responses?.first()?.status, 201) - } -} diff --git a/simplified-tests/src/test/resources/org/nypl/simplified/tests/bookmarks/annotations-dump-20240716.json b/simplified-tests/src/test/resources/org/nypl/simplified/tests/bookmarks/annotations-dump-20240716.json new file mode 100644 index 000000000..d3e8a889e --- /dev/null +++ b/simplified-tests/src/test/resources/org/nypl/simplified/tests/bookmarks/annotations-dump-20240716.json @@ -0,0 +1,259 @@ +{ + "@context": [ + "http://www.w3.org/ns/anno.jsonld", + "http://www.w3.org/ns/ldp.jsonld" + ], + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/", + "type": [ + "BasicContainer", + "AnnotationCollection" + ], + "total": 14, + "first": { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/", + "type": "AnnotationPage", + "items": [ + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17420", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:78057c89-c905-458e-ad2e-9e3c93d2ac2e", + "http://librarysimplified.org/terms/time": "2024-07-15T18:43:38Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-15T18:43:38Z\",\"locator\":{\"@version\":2,\"readingOrderItemOffsetMilliseconds\":1494666,\"readingOrderItem\":\"3ba5bacd-43bb-40e7-a7a6-6d76ac9e11b1.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\"},\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9798350414066" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17422", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:78057c89-c905-458e-ad2e-9e3c93d2ac2e", + "http://librarysimplified.org/terms/time": "2024-07-15T18:42:49Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-07-15T18:42:49Z\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":1493621,\"readingOrderItem\":\"3ba5bacd-43bb-40e7-a7a6-6d76ac9e11b1.MP3.mp3\",\"@version\":2}}" + }, + "source": "urn:isbn:9798350414066" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17421", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:78057c89-c905-458e-ad2e-9e3c93d2ac2e", + "http://librarysimplified.org/terms/time": "2024-07-15T18:42:43Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-15T18:42:43Z\",\"annotationId\":\"\",\"locator\":{\"readingOrderItem\":\"3ba5bacd-43bb-40e7-a7a6-6d76ac9e11b1.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":578206,\"@version\":2}}" + }, + "source": "urn:isbn:9798350414066" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17419", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:78057c89-c905-458e-ad2e-9e3c93d2ac2e", + "http://librarysimplified.org/terms/time": "2024-07-15T18:42:35Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-15T18:42:35Z\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"3ba5bacd-43bb-40e7-a7a6-6d76ac9e11b1.MP3.mp3\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":3647},\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9798350414066" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17122", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Prologue", + "http://librarysimplified.org/terms/device": "null", + "http://librarysimplified.org/terms/progressWithinBook": 0.0033661465, + "http://librarysimplified.org/terms/time": "2024-07-10T15:23:11Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItem\":\"eb2836dc-2269-4c3b-81ea-41a9e2b8933a.MP3.mp3\",\"readingOrderItemOffsetMilliseconds\":98756}" + }, + "source": "urn:isbn:9798350407525" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17179", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Opening Credits", + "http://librarysimplified.org/terms/device": "null", + "http://librarysimplified.org/terms/progressWithinBook": 0.000029573761, + "http://librarysimplified.org/terms/time": "2024-06-27T16:08:18Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItem\":\"446034af-03f8-44a2-bbfb-9b603fb938d5.MP3.mp3\",\"readingOrderItemOffsetMilliseconds\":3329}" + }, + "source": "urn:isbn:9798350400656" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17177", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:f9f09cc7-7355-494f-8390-abd23de794fc", + "http://librarysimplified.org/terms/progressWithinBook": 0.0, + "http://librarysimplified.org/terms/time": "2024-06-27T12:03:48Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorPage\",\"@version\":1,\"page\":5}" + }, + "source": "urn:uuid:fd53d6d1-da9c-437b-8f15-acd035c14220" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17057", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "003_UnderAWhiteSky_Epigraph", + "http://librarysimplified.org/terms/device": "null", + "http://librarysimplified.org/terms/progressWithinBook": 0.0019163245, + "http://librarysimplified.org/terms/time": "2024-06-24T14:14:03Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItem\":\"https://listen.audio.prod-northamerica.cantookaudio.com/item/bc8dac33-102e-4776-8d44-80c4d0f1e109/cdc041b5-ee3e-4e2d-ab90-fa17f17f803a.mp4\",\"readingOrderItemOffsetMilliseconds\":12857}" + }, + "source": "urn:isbn:9781508255277" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17056", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Introduction", + "http://librarysimplified.org/terms/device": "urn:uuid:f9f09cc7-7355-494f-8390-abd23de794fc", + "http://librarysimplified.org/terms/time": "2024-06-20T13:14:05Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"chapter\":0,\"startOffset\":0,\"time\":872,\"part\":0,\"title\":\"Introduction\",\"audiobookID\":\"urn:librarysimplified.org/terms/id/Bibliotheca%20ID/76y7789\",\"duration\":12000}" + }, + "source": "urn:librarysimplified.org/terms/id/Bibliotheca%20ID/76y7789" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17055", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Track 1", + "http://librarysimplified.org/terms/device": "urn:uuid:f9f09cc7-7355-494f-8390-abd23de794fc", + "http://librarysimplified.org/terms/time": "2024-06-20T13:12:40Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"chapter\":0,\"startOffset\":0,\"time\":764,\"part\":0,\"title\":\"Track 1\",\"audiobookID\":\"urn:librarysimplified.org/terms/id/Overdrive%20ID/ebff8d7b-c9a0-4478-a1ed-63b64e8386c5\",\"duration\":4546664}" + }, + "source": "urn:librarysimplified.org/terms/id/Overdrive%20ID/ebff8d7b-c9a0-4478-a1ed-63b64e8386c5" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17054", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/device": "urn:uuid:f9f09cc7-7355-494f-8390-abd23de794fc", + "http://librarysimplified.org/terms/time": "2024-06-20T13:12:10Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorPage\",\"page\":2}" + }, + "source": "urn:uuid:4853f8c5-6412-4abe-a29d-6dd9710f90ce" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17053", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Intro Credits", + "http://librarysimplified.org/terms/device": "urn:uuid:f9f09cc7-7355-494f-8390-abd23de794fc", + "http://librarysimplified.org/terms/time": "2024-06-20T13:11:38Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"chapter\":0,\"startOffset\":0,\"time\":1751,\"part\":0,\"title\":\"Intro Credits\",\"audiobookID\":\"urn:uuid:4535e34b-ae23-47ed-9778-880389d50d33\",\"duration\":14038}" + }, + "source": "urn:uuid:4535e34b-ae23-47ed-9778-880389d50d33" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17052", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter: 1", + "http://librarysimplified.org/terms/device": "urn:uuid:f9f09cc7-7355-494f-8390-abd23de794fc", + "http://librarysimplified.org/terms/time": "2024-06-20T13:11:22Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"chapter\":0,\"startOffset\":0,\"time\":1751,\"part\":0,\"title\":\"Chapter: 1\",\"audiobookID\":\"urn:uuid:18efd9db-fae4-479f-ad4a-13b15b430317\",\"duration\":3211180}" + }, + "source": "urn:uuid:18efd9db-fae4-479f-ad4a-13b15b430317" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17051", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 1", + "http://librarysimplified.org/terms/device": "urn:uuid:f9f09cc7-7355-494f-8390-abd23de794fc", + "http://librarysimplified.org/terms/time": "2024-06-20T13:11:00Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"chapter\":0,\"startOffset\":0,\"time\":2000,\"part\":0,\"title\":\"Chapter 1\",\"audiobookID\":\"urn:isbn:9781603937368\",\"duration\":266000}" + }, + "source": "urn:isbn:9781603937368" + } + } + ] + } +} diff --git a/simplified-tests/src/test/resources/org/nypl/simplified/tests/bookmarks/annotations-dump-20240805.json b/simplified-tests/src/test/resources/org/nypl/simplified/tests/bookmarks/annotations-dump-20240805.json new file mode 100644 index 000000000..d9f2ef5ed --- /dev/null +++ b/simplified-tests/src/test/resources/org/nypl/simplified/tests/bookmarks/annotations-dump-20240805.json @@ -0,0 +1,3571 @@ +{ + "@context": [ + "http://www.w3.org/ns/anno.jsonld", + "http://www.w3.org/ns/ldp.jsonld" + ], + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/", + "type": [ + "BasicContainer", + "AnnotationCollection" + ], + "total": 209, + "first": { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/", + "type": "AnnotationPage", + "items": [ + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/18197", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter: 1", + "http://librarysimplified.org/terms/device": "urn:uuid:0a681a0a-01f7-4fa1-a0d3-e1f92f2e26c3", + "http://librarysimplified.org/terms/time": "2024-08-02T13:51:05Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"chapter\":0,\"startOffset\":0,\"time\":0,\"part\":0,\"title\":\"Chapter: 1\",\"audiobookID\":\"urn:uuid:2ccec645-341b-45b7-871e-a0d0160fa42c\",\"duration\":334000}" + }, + "source": "urn:uuid:2ccec645-341b-45b7-871e-a0d0160fa42c" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/18201", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:5dffc250-19a6-4607-a5e3-128131e2e8f4", + "http://librarysimplified.org/terms/time": "2024-08-02T13:47:10Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"readingOrderItemOffsetMilliseconds\":2839047,\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/2ccec645-341b-45b7-871e-a0d0160fa42c\\/assets\\/content\\/SID-0000004246246_0007_64k.mp3\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2},\"annotationId\":\"\",\"timeStamp\":\"2024-08-02T13:47:10Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:uuid:2ccec645-341b-45b7-871e-a0d0160fa42c" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/18200", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:5dffc250-19a6-4607-a5e3-128131e2e8f4", + "http://librarysimplified.org/terms/time": "2024-08-02T13:47:03Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-08-02T13:47:03Z\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/2ccec645-341b-45b7-871e-a0d0160fa42c\\/assets\\/content\\/SID-0000004246246_0002_64k.mp3\",\"readingOrderItemOffsetMilliseconds\":181624}}" + }, + "source": "urn:uuid:2ccec645-341b-45b7-871e-a0d0160fa42c" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/18199", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:5dffc250-19a6-4607-a5e3-128131e2e8f4", + "http://librarysimplified.org/terms/time": "2024-08-02T13:46:52Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-08-02T13:46:52Z\",\"annotationId\":\"\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":171275,\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/2ccec645-341b-45b7-871e-a0d0160fa42c\\/assets\\/content\\/SID-0000004246246_0002_64k.mp3\"},\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:uuid:2ccec645-341b-45b7-871e-a0d0160fa42c" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/18198", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:5dffc250-19a6-4607-a5e3-128131e2e8f4", + "http://librarysimplified.org/terms/time": "2024-08-02T13:46:37Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"locator\":{\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/2ccec645-341b-45b7-871e-a0d0160fa42c\\/assets\\/content\\/SID-0000004246246_0001_64k.mp3\",\"readingOrderItemOffsetMilliseconds\":141829,\"@version\":2,\"@type\":\"LocatorAudioBookTime\"},\"timeStamp\":\"2024-08-02T13:46:37Z\"}" + }, + "source": "urn:uuid:2ccec645-341b-45b7-871e-a0d0160fa42c" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17106", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:e1e1d96c-c85f-4ac4-b8c2-b64c3a7299ed", + "http://librarysimplified.org/terms/time": "2024-07-30T16:35:17Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":4007,\"@version\":2,\"annotationId\":\"\",\"timeStamp\":\"2024-07-30T16:35:17Z\",\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/b82c3be5-255b-4b9f-b4b9-b3a259fc5f46\\/assets\\/content\\/SID-0000004241193_0003_64k.mp3\"}" + }, + "source": "urn:uuid:b82c3be5-255b-4b9f-b4b9-b3a259fc5f46" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17116", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:e1e1d96c-c85f-4ac4-b8c2-b64c3a7299ed", + "http://librarysimplified.org/terms/time": "2024-07-30T16:21:56Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"@version\":2,\"readingOrderItem\":\"https:\\/\\/ofsdirect.api.overdrive.com\\/contentfile?body=eyJjb250ZW50SUQiOiIyNTY5RURDQS04NkFELTQ4OEYtQTA5Ri0zMDU5Q0RFMkNFNDYtNjI1IiwiY29udGVudElEVHlwZSI6IkNvbnRlbnRSZXNlcnZlSUQiLCJjb250ZW50RXhwaXJhdGlvblVUQyI6IjIwMjQtMDctMzFUMTY6MTk6MDYuMjA2NTAyOVoiLCJVUkxFeHBpcmF0aW9uVVRDIjoiMjAyNC0wNy0zMFQxNzoxOTowNi40MzQ2NzU5WiIsInNvdXJjZUludGVyYWN0aW9uSUQiOiI5OTgyNzc2NDM2NTMuNjI4LjE1Mjc0NGQxLTAyYmMtNGM4Yy04OGNkLTM0NDJjOWRmOGZlNSIsInNvdXJjZUFQSVVzZXIiOiJvZHBsdXRvIiwiZm9ybWF0U3BlY2lmaWMiOnsiQlVJRCI6IjkyNTdiNjVhYzYyNmM4NzY1MGU5MDVhZjc3ZTc4NGIxIiwicGF0aCI6InsyNTY5RURDQS04NkFELTQ4OEYtQTA5Ri0zMDU5Q0RFMkNFNDZ9Rm10NDI1LVBhcnQwMi5tcDMifX0%3D&s=a5088e5cccce9f04dd7b5fbe59b96ad156d1996410c94609760643fbb6b3580f\",\"readingOrderItemOffsetMilliseconds\":6226,\"timeStamp\":\"2024-07-30T16:21:56Z\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Overdrive%20ID/2569edca-86ad-488f-a09f-3059cde2ce46" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/18180", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:e1e1d96c-c85f-4ac4-b8c2-b64c3a7299ed", + "http://librarysimplified.org/terms/time": "2024-07-30T04:34:01Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"readingOrderItem\":\"5e6e0c56-4124-48c1-b857-beed0b5d7a56.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"timeStamp\":\"2024-07-30T04:34:01Z\",\"readingOrderItemOffsetMilliseconds\":3634,\"@version\":2}" + }, + "source": "urn:isbn:9781603935487" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17850", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:e1e1d96c-c85f-4ac4-b8c2-b64c3a7299ed", + "http://librarysimplified.org/terms/time": "2024-07-30T04:33:11Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"readingOrderItemOffsetMilliseconds\":5241,\"readingOrderItem\":\"241f322e-2170-461f-b8d2-2a5c3a8b105f.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"annotationId\":\"\",\"timeStamp\":\"2024-07-30T04:33:11Z\"}" + }, + "source": "urn:isbn:9781603932639" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17376", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:e1e1d96c-c85f-4ac4-b8c2-b64c3a7299ed", + "http://librarysimplified.org/terms/time": "2024-07-30T04:12:19Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"readingOrderItemOffsetMilliseconds\":1000,\"timeStamp\":\"2024-07-30T04:12:19Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItem\":\"urn:org.thepalaceproject:findaway:6:1\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Bibliotheca%20ID/eb55xg9" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17645", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:e1e1d96c-c85f-4ac4-b8c2-b64c3a7299ed", + "http://librarysimplified.org/terms/time": "2024-07-29T04:15:31Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/5203ea4c-ae6e-4a13-920e-af03d183bcba\\/assets\\/content\\/SID-0000004185980_0001_64k.mp3\",\"readingOrderItemOffsetMilliseconds\":433,\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"timeStamp\":\"2024-07-29T04:15:31Z\"}" + }, + "source": "urn:uuid:5203ea4c-ae6e-4a13-920e-af03d183bcba" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17374", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:e1e1d96c-c85f-4ac4-b8c2-b64c3a7299ed", + "http://librarysimplified.org/terms/time": "2024-07-29T04:15:07Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"readingOrderItem\":\"https:\\/\\/app.unlimitedlistens.com\\/api\\/track\\/4f3c105f-00e5-4886-87a4-31e997486058\",\"annotationId\":\"\",\"readingOrderItemOffsetMilliseconds\":94926,\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-29T04:15:07Z\",\"@version\":2}" + }, + "source": "urn:isbn:9781802540109" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17378", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:e1e1d96c-c85f-4ac4-b8c2-b64c3a7299ed", + "http://librarysimplified.org/terms/time": "2024-07-29T04:05:15Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-29T04:05:15Z\",\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/c41627c0-1b17-49e9-ba06-f44662efafb9\\/assets\\/content\\/SID-0000003591846_0001_64k.mp3\",\"annotationId\":\"\",\"readingOrderItemOffsetMilliseconds\":2816}" + }, + "source": "urn:uuid:c41627c0-1b17-49e9-ba06-f44662efafb9" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17317", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:ae64c7f6-218f-4f3b-b5a4-9e3e78eb25e1", + "http://librarysimplified.org/terms/time": "2024-07-26T16:36:50Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-26T16:36:50Z\",\"@version\":2,\"readingOrderItem\":\"https:\\/\\/ofsdirect.api.overdrive.com\\/contentfile?body=eyJjb250ZW50SUQiOiI0QTI3REE4Ri1GNDlCLTRGOUQtQjUyNS1CNjA4QjYyNEU0RTAtNjI1IiwiY29udGVudElEVHlwZSI6IkNvbnRlbnRSZXNlcnZlSUQiLCJjb250ZW50RXhwaXJhdGlvblVUQyI6IjIwMjQtMDctMjdUMTY6MzQ6MzIuODg3NjcxWiIsIlVSTEV4cGlyYXRpb25VVEMiOiIyMDI0LTA3LTI2VDE3OjM0OjMyLjkxOTM3OTlaIiwic291cmNlSW50ZXJhY3Rpb25JRCI6Ijk5ODI3Nzk4ODMyNy4xMjcuZTE0NjQ5ZDAtZDIxOS00NzdlLWI3NTUtYWZiZjZlMzAyZjg3Iiwic291cmNlQVBJVXNlciI6Im9kcGx1dG8iLCJmb3JtYXRTcGVjaWZpYyI6eyJCVUlEIjoiYjE5YjY1Y2VlMGY0MjJmZWRhZDc0MmZkMWFmNGVmY2QiLCJwYXRoIjoiezRBMjdEQThGLUY0OUItNEY5RC1CNTI1LUI2MDhCNjI0RTRFMH1GbXQ0MjUtUGFydDAxLm1wMyJ9fQ%3D%3D&s=ca3a9fe93de2e9145ff084329e9b27a6fa31c8b5c722b18df2c44d86b35cef13\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":4244002}" + }, + "source": "urn:librarysimplified.org/terms/id/Overdrive%20ID/4a27da8f-f49b-4f9d-b525-b608b624e4e0" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17651", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:ae64c7f6-218f-4f3b-b5a4-9e3e78eb25e1", + "http://librarysimplified.org/terms/time": "2024-07-26T16:32:25Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-26T16:32:25Z\",\"@version\":2,\"readingOrderItem\":\"https:\\/\\/app.unlimitedlistens.com\\/api\\/track\\/4f61e2a0-cae2-4637-8efd-bb0a0236ff16\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":0}" + }, + "source": "urn:isbn:9781646894581" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/18176", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:ae64c7f6-218f-4f3b-b5a4-9e3e78eb25e1", + "http://librarysimplified.org/terms/time": "2024-07-26T16:31:50Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-26T16:31:50Z\",\"@version\":2,\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/57c5f26e-88fb-45a7-b5a0-3aa2d81b532f\\/assets\\/content\\/SID-0000004184580_0001_64k.mp3\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":0}" + }, + "source": "urn:uuid:57c5f26e-88fb-45a7-b5a0-3aa2d81b532f" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17372", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:ae64c7f6-218f-4f3b-b5a4-9e3e78eb25e1", + "http://librarysimplified.org/terms/time": "2024-07-26T16:29:07Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-26T16:29:07Z\",\"@version\":2,\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/7bd5c6ee-0fd9-48d7-83c5-183e11fdb8cf\\/assets\\/content\\/SID-0000004245780_0001_64k.mp3\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":5840}" + }, + "source": "urn:uuid:7bd5c6ee-0fd9-48d7-83c5-183e11fdb8cf" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17167", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:9fb2e813-0403-4cb3-b3de-ac3a780d3b38", + "http://librarysimplified.org/terms/time": "2024-07-23T14:38:15Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-07-23T14:38:15Z\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":43822,\"readingOrderItem\":\"fbbc31cd-55d0-480c-8b97-a1f6cedb70b4.MP3.mp3\",\"@version\":2}}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17852", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:9fb2e813-0403-4cb3-b3de-ac3a780d3b38", + "http://librarysimplified.org/terms/time": "2024-07-23T14:37:58Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-07-23T14:37:58Z\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@version\":2,\"readingOrderItemOffsetMilliseconds\":27962,\"readingOrderItem\":\"fbbc31cd-55d0-480c-8b97-a1f6cedb70b4.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\"}}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17848", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:063212f9-75fe-4c94-8742-849ffd85e328", + "http://librarysimplified.org/terms/time": "2024-07-23T13:39:54Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"timeStamp\":\"2024-07-23T13:39:54Z\",\"locator\":{\"readingOrderItem\":\"5f79cf5d-f2d0-4243-8db8-b325eb7f5e4a.MP3.mp3\",\"readingOrderItemOffsetMilliseconds\":41129,\"@version\":2,\"@type\":\"LocatorAudioBookTime\"}}" + }, + "source": "urn:isbn:9780593586990" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17851", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:063212f9-75fe-4c94-8742-849ffd85e328", + "http://librarysimplified.org/terms/time": "2024-07-23T13:39:36Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-23T13:39:36Z\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"https:\\/\\/app.unlimitedlistens.com\\/api\\/track\\/7005779d-3623-4ce0-9f87-f58d0b7b749e\",\"readingOrderItemOffsetMilliseconds\":0,\"@version\":2},\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781496473462" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17847", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:063212f9-75fe-4c94-8742-849ffd85e328", + "http://librarysimplified.org/terms/time": "2024-07-23T13:38:49Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":75521,\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItem\":\"https:\\/\\/app.unlimitedlistens.com\\/api\\/track\\/0a4da60b-dd62-40a6-bf13-1eb22ccfdb8e\"},\"annotationId\":\"\",\"timeStamp\":\"2024-07-23T13:38:49Z\"}" + }, + "source": "urn:isbn:9781646893928" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17849", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:063212f9-75fe-4c94-8742-849ffd85e328", + "http://librarysimplified.org/terms/time": "2024-07-23T13:35:19Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/59617e89-c1d0-4eea-b66a-183229b2a62d\\/assets\\/content\\/SID-0000004245139_0001_64k.mp3\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":1988},\"timeStamp\":\"2024-07-23T13:35:19Z\"}" + }, + "source": "urn:uuid:59617e89-c1d0-4eea-b66a-183229b2a62d" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17846", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:063212f9-75fe-4c94-8742-849ffd85e328", + "http://librarysimplified.org/terms/time": "2024-07-23T13:20:26Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-07-23T13:20:26Z\",\"locator\":{\"readingOrderItem\":\"bed21c98-b80d-4a36-8166-e1cec00520a4.MP3.mp3\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":6212,\"@type\":\"LocatorAudioBookTime\"},\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17845", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:9af1c59c-78e9-44be-bf95-61e65e256c23", + "http://librarysimplified.org/terms/time": "2024-07-23T12:45:09Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-23T12:45:09Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/7bd5c6ee-0fd9-48d7-83c5-183e11fdb8cf\\/assets\\/content\\/SID-0000004245780_0002_64k.mp3\",\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":22506}}" + }, + "source": "urn:uuid:7bd5c6ee-0fd9-48d7-83c5-183e11fdb8cf" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17844", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:9af1c59c-78e9-44be-bf95-61e65e256c23", + "http://librarysimplified.org/terms/time": "2024-07-23T12:38:46Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-23T12:38:46Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@version\":2,\"readingOrderItem\":\"fbbc31cd-55d0-480c-8b97-a1f6cedb70b4.MP3.mp3\",\"readingOrderItemOffsetMilliseconds\":2737301,\"@type\":\"LocatorAudioBookTime\"}}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17670", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:49b8cc18-9d48-4153-a2c4-4c882a95322d", + "http://librarysimplified.org/terms/time": "2024-07-23T11:47:33Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"@version\":2,\"readingOrderItem\":\"https:\\/\\/ofsdirect.api.overdrive.com\\/contentfile?body=eyJjb250ZW50SUQiOiI1MzVFODMyQy1BMURDLTQyNjEtODRGRS1ENUEyOEY3MzM2NDAtNjI1IiwiY29udGVudElEVHlwZSI6IkNvbnRlbnRSZXNlcnZlSUQiLCJjb250ZW50RXhwaXJhdGlvblVUQyI6IjIwMjQtMDctMjRUMTE6MTY6MjguOTAzMDcxM1oiLCJVUkxFeHBpcmF0aW9uVVRDIjoiMjAyNC0wNy0yM1QxMjoxNjoyOS41MTQxODQxWiIsInNvdXJjZUludGVyYWN0aW9uSUQiOiI5OTgyNzgyNjY2MTAuNTQ4LjZmNmRlYzYyLTU3YjYtNDQ5My04MTNhLTAyY2NmMGQ0NWRkMyIsInNvdXJjZUFQSVVzZXIiOiJvZHBsdXRvIiwiZm9ybWF0U3BlY2lmaWMiOnsiQlVJRCI6IjIxYTJhMTZiYzBhNmRjYmYxYzJmYTMzNjRkY2VhNTdkIiwicGF0aCI6Ins1MzVFODMyQy1BMURDLTQyNjEtODRGRS1ENUEyOEY3MzM2NDB9Rm10NDI1LVBhcnQwMy5tcDMifX0%3D&s=edd688d3e62063593b843a5656d4a0bcff0dba1ed4c8b621adb87af32ba3e65a\",\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":12296},\"annotationId\":\"\",\"timeStamp\":\"2024-07-23T11:47:33Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Overdrive%20ID/535e832c-a1dc-4261-84fe-d5a28f733640" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17668", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:acc783cb-a8a2-4f8a-9a78-f882d2035f00", + "http://librarysimplified.org/terms/time": "2024-07-22T18:28:22Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-22T18:28:22Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@version\":2,\"readingOrderItem\":\"https:\\/\\/ofsdirect.api.overdrive.com\\/contentfile?body=eyJjb250ZW50SUQiOiI0QTI3REE4Ri1GNDlCLTRGOUQtQjUyNS1CNjA4QjYyNEU0RTAtNjI1IiwiY29udGVudElEVHlwZSI6IkNvbnRlbnRSZXNlcnZlSUQiLCJjb250ZW50RXhwaXJhdGlvblVUQyI6IjIwMjQtMDctMjNUMTg6Mjc6MzAuNTEzMDU2NloiLCJVUkxFeHBpcmF0aW9uVVRDIjoiMjAyNC0wNy0yMlQxOToyNzozMC42MjYxNzIyWiIsInNvdXJjZUludGVyYWN0aW9uSUQiOiI5OTgyNzgzMjcxNDkuNDA1LjE2YzcyMzg2LTc4YTUtNDJmZS05MzI5LTMxNzI4YjhiYWExMyIsInNvdXJjZUFQSVVzZXIiOiJvZHBsdXRvIiwiZm9ybWF0U3BlY2lmaWMiOnsiQlVJRCI6ImIxOWI2NWNlZTBmNDIyZmVkYWQ3NDJmZDFhZjRlZmNkIiwicGF0aCI6Ins0QTI3REE4Ri1GNDlCLTRGOUQtQjUyNS1CNjA4QjYyNEU0RTB9Rm10NDI1LVBhcnQwMS5tcDMifX0%3D&s=89500eb15e8952507737f4515de5b381e4ca8837e677ef11f0c4103affe8475c\",\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":23412}}" + }, + "source": "urn:librarysimplified.org/terms/id/Overdrive%20ID/4a27da8f-f49b-4f9d-b525-b608b624e4e0" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17650", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:49b8cc18-9d48-4153-a2c4-4c882a95322d", + "http://librarysimplified.org/terms/time": "2024-07-22T17:10:12Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-22T17:10:12Z\",\"locator\":{\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"https:\\/\\/ofsdirect.api.overdrive.com\\/contentfile?body=eyJjb250ZW50SUQiOiI0QTI3REE4Ri1GNDlCLTRGOUQtQjUyNS1CNjA4QjYyNEU0RTAtNjI1IiwiY29udGVudElEVHlwZSI6IkNvbnRlbnRSZXNlcnZlSUQiLCJjb250ZW50RXhwaXJhdGlvblVUQyI6IjIwMjQtMDctMjNUMTc6MDk6NTMuNjg3NjUxNFoiLCJVUkxFeHBpcmF0aW9uVVRDIjoiMjAyNC0wNy0yMlQxODowOTo1My43NTM3OTg3WiIsInNvdXJjZUludGVyYWN0aW9uSUQiOiI5OTgyNzgzMzE4MDYuMjc3LjBkODY1NzNiLTgzNDMtNGZjYS04NTIxLTQ0ZmYzOTdmY2Q0MiIsInNvdXJjZUFQSVVzZXIiOiJvZHBsdXRvIiwiZm9ybWF0U3BlY2lmaWMiOnsiQlVJRCI6ImIxOWI2NWNlZTBmNDIyZmVkYWQ3NDJmZDFhZjRlZmNkIiwicGF0aCI6Ins0QTI3REE4Ri1GNDlCLTRGOUQtQjUyNS1CNjA4QjYyNEU0RTB9Rm10NDI1LVBhcnQwMS5tcDMifX0%3D&s=1e1bf1cc0fad5a3980bb6abd99d8fc1f7816c07a49583eefac68c492eabf3f03\",\"readingOrderItemOffsetMilliseconds\":14282},\"annotationId\":\"\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Overdrive%20ID/4a27da8f-f49b-4f9d-b525-b608b624e4e0" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17648", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:d1cf46a6-1d9a-4bbf-8745-bacc49340f3e", + "http://librarysimplified.org/terms/time": "2024-07-22T15:23:11Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-22T15:23:11Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/5c8c00b6-a822-43d4-a721-c82a492555e2\\/assets\\/content\\/SID-0000004244401_0001_64k.mp3\",\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":6753}}" + }, + "source": "urn:uuid:5c8c00b6-a822-43d4-a721-c82a492555e2" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17647", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:d1cf46a6-1d9a-4bbf-8745-bacc49340f3e", + "http://librarysimplified.org/terms/time": "2024-07-22T15:09:10Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-22T15:09:10Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@version\":2,\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/2c85c16e-c0eb-45f6-be36-2d87b7e71af7\\/assets\\/content\\/SID-0000003606385_0001_64k.mp3\",\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":7445}}" + }, + "source": "urn:uuid:2c85c16e-c0eb-45f6-be36-2d87b7e71af7" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17646", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:d1cf46a6-1d9a-4bbf-8745-bacc49340f3e", + "http://librarysimplified.org/terms/time": "2024-07-22T14:44:46Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-22T14:44:46Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItem\":\"urn:org.thepalaceproject:findaway:nil:0\",\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":5000}}" + }, + "source": "urn:librarysimplified.org/terms/id/Bibliotheca%20ID/7q11h89" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17415", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-12T04:34:39Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-12T04:34:39Z\",\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"locator\":{\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/e3499472-442c-4320-8cfd-ef5a33b8f896\\/assets\\/content\\/SID-0000004248315_0001_64k.mp3\",\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":0}}" + }, + "source": "urn:uuid:e3499472-442c-4320-8cfd-ef5a33b8f896" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17414", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-12T04:32:29Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItem\":\"https:\\/\\/ofsdirect.api.overdrive.com\\/contentfile?body=eyJjb250ZW50SUQiOiIwMTE2NzA3MS00QzBELTQ5MjYtQTFDOS1FRjg4NEMwRUI3MjQtNjI1IiwiY29udGVudElEVHlwZSI6IkNvbnRlbnRSZXNlcnZlSUQiLCJjb250ZW50RXhwaXJhdGlvblVUQyI6IjIwMjQtMDctMTNUMDQ6MzE6MzAuNTk3NDI4WiIsIlVSTEV4cGlyYXRpb25VVEMiOiIyMDI0LTA3LTEyVDA1OjMxOjMwLjc1NzIzNTlaIiwic291cmNlSW50ZXJhY3Rpb25JRCI6Ijk5ODI3OTI0MTMwOS40MzAuNWRkNDUyOWItNzkzYy00OTQzLWFmYTItNWJmY2YyZTFlYTIwIiwic291cmNlQVBJVXNlciI6Im9kcGx1dG8iLCJmb3JtYXRTcGVjaWZpYyI6eyJCVUlEIjoiMmQ0OWM2YzEzODRjY2NjNzlkOWM5YzM1MDliYzk0OWYiLCJwYXRoIjoiezAxMTY3MDcxLTRDMEQtNDkyNi1BMUM5LUVGODg0QzBFQjcyNH1GbXQ0MjUtUGFydDAxLm1wMyJ9fQ%3D%3D&s=0692365551b04a212d2c0d57290e66c397a6c7d0345117c893c4a763b991bbde\",\"readingOrderItemOffsetMilliseconds\":1632,\"@type\":\"LocatorAudioBookTime\",\"@version\":2},\"timeStamp\":\"2024-07-12T04:32:29Z\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Overdrive%20ID/01167071-4c0d-4926-a1c9-ef884c0eb724" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17413", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-12T04:31:10Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-12T04:31:10Z\",\"locator\":{\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/b309844e-7d4e-403e-945b-fbc78acd5e03\\/assets\\/content\\/SID-0000003591954_0049_64k.mp3\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":1530}}" + }, + "source": "urn:uuid:b309844e-7d4e-403e-945b-fbc78acd5e03" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17412", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-12T04:29:00Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-12T04:29:00Z\",\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"locator\":{\"@version\":2,\"readingOrderItem\":\"urn:org.thepalaceproject:findaway:0:0\",\"readingOrderItemOffsetMilliseconds\":0,\"@type\":\"LocatorAudioBookTime\"}}" + }, + "source": "urn:librarysimplified.org/terms/id/Bibliotheca%20ID/76y7789" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17411", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-12T04:28:10Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-12T04:28:10Z\",\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":944,\"readingOrderItem\":\"https:\\/\\/app.unlimitedlistens.com\\/api\\/track\\/ab95cd06-1ade-4b79-ba8e-8cbafa630ead\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2}}" + }, + "source": "urn:isbn:9781802540024" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17410", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-12T04:27:49Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItem\":\"https:\\/\\/app.unlimitedlistens.com\\/api\\/track\\/a9f186f4-3d02-4052-b2ba-caf077a29f26\",\"readingOrderItemOffsetMilliseconds\":0,\"@version\":2,\"@type\":\"LocatorAudioBookTime\"},\"timeStamp\":\"2024-07-12T04:27:49Z\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781646896479" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17409", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-12T04:26:31Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"readingOrderItemOffsetMilliseconds\":1333,\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/5a598268-a37a-4f4c-b407-038cd0da39f4\\/assets\\/content\\/SID-0000004242382_0001_64k.mp3\",\"@version\":2,\"@type\":\"LocatorAudioBookTime\"},\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-12T04:26:31Z\",\"annotationId\":\"\"}" + }, + "source": "urn:uuid:5a598268-a37a-4f4c-b407-038cd0da39f4" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17408", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-12T03:40:51Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-12T03:40:51Z\",\"locator\":{\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":3757,\"readingOrderItem\":\"64b0dce3-578f-4719-b799-f41a58a131c1.MP3.mp3\"},\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781603934886" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17216", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-12T03:40:09Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-12T03:40:09Z\",\"annotationId\":\"\",\"locator\":{\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"7806a5c0-b9a9-4bfe-a5b2-3558878b3642.MP3.mp3\",\"readingOrderItemOffsetMilliseconds\":5823}}" + }, + "source": "urn:isbn:9798350410938" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17211", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-12T03:31:34Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-07-12T03:31:34Z\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItem\":\"29b88050-6995-48af-829a-80040d02c8fb.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":18964}}" + }, + "source": "urn:isbn:9798350417760" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17215", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-12T03:28:45Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-12T03:28:45Z\",\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"locator\":{\"@version\":2,\"readingOrderItem\":\"b629a206-8a9f-4372-b019-59ea83cf8217.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":40714}}" + }, + "source": "urn:isbn:9798350403800" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17407", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-12T03:26:40Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItem\":\"https:\\/\\/listen.audio.prod-northamerica.cantookaudio.com\\/item\\/3e863ebd-b1f8-4435-93fd-e7d1bed93c61\\/0e893f45-b90e-4fff-950a-0228e385d8fb.mp4\",\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":62079,\"@version\":2},\"annotationId\":\"\",\"timeStamp\":\"2024-07-12T03:26:40Z\"}" + }, + "source": "urn:isbn:9781603935517" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17381", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:49b8cc18-9d48-4153-a2c4-4c882a95322d", + "http://librarysimplified.org/terms/time": "2024-07-11T13:50:54Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":1841,\"readingOrderItem\":\"https:\\/\\/ofsdirect.api.overdrive.com\\/contentfile?body=eyJjb250ZW50SUQiOiI0QTI3REE4Ri1GNDlCLTRGOUQtQjUyNS1CNjA4QjYyNEU0RTAtNjI1IiwiY29udGVudElEVHlwZSI6IkNvbnRlbnRSZXNlcnZlSUQiLCJjb250ZW50RXhwaXJhdGlvblVUQyI6IjIwMjQtMDctMTJUMTM6NTA6MDMuMjE0ODU4M1oiLCJVUkxFeHBpcmF0aW9uVVRDIjoiMjAyNC0wNy0xMVQxNDo1MDowMy41NTE3MzE1WiIsInNvdXJjZUludGVyYWN0aW9uSUQiOiI5OTgyNzkyOTQxOTYuNTExLmJmM2JlOWY1LTdmYTQtNGI4Yi04ZDRkLThjZWYwYzFlMzYyNSIsInNvdXJjZUFQSVVzZXIiOiJvZHBsdXRvIiwiZm9ybWF0U3BlY2lmaWMiOnsiQlVJRCI6ImIxOWI2NWNlZTBmNDIyZmVkYWQ3NDJmZDFhZjRlZmNkIiwicGF0aCI6Ins0QTI3REE4Ri1GNDlCLTRGOUQtQjUyNS1CNjA4QjYyNEU0RTB9Rm10NDI1LVBhcnQwMy5tcDMifX0%3D&s=53abc5e0e73fa1eb83061aa96a61f034348357d089ec851e6721b59ae1a1dafb\",\"@version\":2},\"timeStamp\":\"2024-07-11T13:50:54Z\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Overdrive%20ID/4a27da8f-f49b-4f9d-b525-b608b624e4e0" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17377", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:6299c588-7c82-4820-927a-168c061ac32c", + "http://librarysimplified.org/terms/time": "2024-07-11T12:28:05Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-11T12:28:05Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItem\":\"urn:org.thepalaceproject:findaway:nil:1\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":102000,\"@type\":\"LocatorAudioBookTime\"}}" + }, + "source": "urn:librarysimplified.org/terms/id/Bibliotheca%20ID/eb55xg9" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17375", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:9afd357f-f5c8-4408-ae94-ef1521132652", + "http://librarysimplified.org/terms/time": "2024-07-11T00:54:51Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-11T00:54:51Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItem\":\"https:\\/\\/app.unlimitedlistens.com\\/api\\/track\\/d2ae6b08-e9a5-4862-849f-aff68ace5b1c\",\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":27866}}" + }, + "source": "urn:isbn:9781802540109" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17170", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:8a2cb788-1f96-4664-b74c-d281ebe27262", + "http://librarysimplified.org/terms/time": "2024-07-10T18:30:01Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@version\":2,\"readingOrderItem\":\"478ec4be-a61a-41b4-aea0-a2f3fd23ce8e.MP3.mp3\",\"readingOrderItemOffsetMilliseconds\":9460,\"@type\":\"LocatorAudioBookTime\"}}" + }, + "source": "urn:isbn:9780061996924" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17366", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-10T04:18:22Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-07-10T04:14:48Z\",\"locator\":{\"timeStamp\":\"2024-07-10T04:14:48Z\",\"annotationId\":\"\",\"locator\":{\"@version\":2,\"readingOrderItemOffsetMilliseconds\":892,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"e7bb33e4-b3e7-4b66-8a47-2bbc423747ad.MP3.mp3\"}},\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17365", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-10T04:18:22Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-10T04:14:44Z\",\"locator\":{\"timeStamp\":\"2024-07-10T04:14:44Z\",\"annotationId\":\"\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"fb924d21-ddff-4252-8d23-0551e262f18e.MP3.mp3\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":7566}},\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17121", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-10T04:08:51Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":2000,\"readingOrderItem\":\"urn:org.thepalaceproject:findaway:1:0\",\"@version\":2},\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-10T04:08:51Z\",\"annotationId\":\"\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Axis%20360%20ID/0012407496" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17123", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-10T03:11:31Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"locator\":{\"readingOrderItem\":\"73a9d9a0-d9e0-40a3-ad29-9342ebb3b4bf.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":7972,\"@version\":2},\"timeStamp\":\"2024-07-10T03:11:31Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9780593557129" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17315", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-10T03:11:10Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":4000,\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItem\":\"urn:org.thepalaceproject:findaway:nil:1\"},\"timeStamp\":\"2024-07-10T03:11:10Z\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Axis%20360%20ID/0012407897" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17291", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-10T03:09:58Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItem\":\"urn:org.thepalaceproject:findaway:nil:0\",\"readingOrderItemOffsetMilliseconds\":0},\"timeStamp\":\"2024-07-10T03:09:57Z\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Bibliotheca%20ID/6g3dfr9" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17287", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-10T03:09:55Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-10T03:09:55Z\",\"locator\":{\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":1265,\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/1f0b1b5c-5206-43bd-85b3-0b960c0fa1e5\\/assets\\/content\\/SID-0000004186229_0001_64k.mp3\"},\"annotationId\":\"\"}" + }, + "source": "urn:uuid:1f0b1b5c-5206-43bd-85b3-0b960c0fa1e5" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17353", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:579832dc-e677-4f79-b062-393e93348c78", + "http://librarysimplified.org/terms/time": "2024-07-09T14:50:28Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-07-09T14:50:28Z\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":52000,\"@version\":2,\"readingOrderItem\":\"urn:org.thepalaceproject:findaway:nil:1\"},\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Axis%20360%20ID/0012407897" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17352", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:579832dc-e677-4f79-b062-393e93348c78", + "http://librarysimplified.org/terms/time": "2024-07-09T14:43:39Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/1f0b1b5c-5206-43bd-85b3-0b960c0fa1e5\\/assets\\/content\\/SID-0000004186229_0001_64k.mp3\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":323824},\"timeStamp\":\"2024-07-09T14:43:39Z\",\"annotationId\":\"\"}" + }, + "source": "urn:uuid:1f0b1b5c-5206-43bd-85b3-0b960c0fa1e5" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17351", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:579832dc-e677-4f79-b062-393e93348c78", + "http://librarysimplified.org/terms/time": "2024-07-09T14:33:05Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-09T14:33:05Z\",\"annotationId\":\"\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":5366000,\"readingOrderItem\":\"urn:org.thepalaceproject:findaway:nil:1\"},\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Bibliotheca%20ID/6g3dfr9" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17350", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:579832dc-e677-4f79-b062-393e93348c78", + "http://librarysimplified.org/terms/time": "2024-07-09T14:23:53Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-09T14:23:53Z\",\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"locator\":{\"@version\":2,\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/b82c3be5-255b-4b9f-b4b9-b3a259fc5f46\\/assets\\/content\\/SID-0000004241193_0001_64k.mp3\",\"readingOrderItemOffsetMilliseconds\":55327,\"@type\":\"LocatorAudioBookTime\"}}" + }, + "source": "urn:uuid:b82c3be5-255b-4b9f-b4b9-b3a259fc5f46" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17107", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:579832dc-e677-4f79-b062-393e93348c78", + "http://librarysimplified.org/terms/time": "2024-07-09T14:21:02Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"readingOrderItemOffsetMilliseconds\":65542,\"@version\":2,\"readingOrderItem\":\"https:\\/\\/app.unlimitedlistens.com\\/api\\/track\\/1b6a33ce-5566-4a6a-a0a1-14daa04a7004\",\"@type\":\"LocatorAudioBookTime\"},\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-09T14:21:02Z\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781646894550" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17349", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:579832dc-e677-4f79-b062-393e93348c78", + "http://librarysimplified.org/terms/time": "2024-07-09T14:20:51Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"https:\\/\\/app.unlimitedlistens.com\\/api\\/track\\/1b6a33ce-5566-4a6a-a0a1-14daa04a7004\",\"readingOrderItemOffsetMilliseconds\":55843},\"timeStamp\":\"2024-07-09T14:20:51Z\"}" + }, + "source": "urn:isbn:9781646894550" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17346", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:b4638fda-b30f-4dc1-a258-650e4a75b5cb", + "http://librarysimplified.org/terms/time": "2024-07-09T13:48:15Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"8b4924b0-406b-43d8-a559-3aee7adc4fc0.MP3.mp3\",\"readingOrderItemOffsetMilliseconds\":137893},\"annotationId\":\"\",\"timeStamp\":\"2024-07-09T13:48:15Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9798350403800" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17345", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 1", + "http://librarysimplified.org/terms/device": "urn:uuid:0a681a0a-01f7-4fa1-a0d3-e1f92f2e26c3", + "http://librarysimplified.org/terms/time": "2024-07-09T13:47:18Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"chapter\":0,\"startOffset\":0,\"time\":1439000,\"part\":0,\"title\":\"Chapter 1\",\"audiobookID\":\"urn:isbn:9798350403800\",\"duration\":2896000}" + }, + "source": "urn:isbn:9798350403800" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17344", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:b4638fda-b30f-4dc1-a258-650e4a75b5cb", + "http://librarysimplified.org/terms/time": "2024-07-09T13:42:40Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-09T13:42:40Z\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItem\":\"8b4924b0-406b-43d8-a559-3aee7adc4fc0.MP3.mp3\",\"readingOrderItemOffsetMilliseconds\":4883}}" + }, + "source": "urn:isbn:9798350403800" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17319", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-08T16:32:19Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItem\":\"https:\\/\\/ofsdirect.api.overdrive.com\\/contentfile?body=eyJjb250ZW50SUQiOiI3RUNBM0M3QS03NTE0LTQ2QzYtQUM3RS1GN0YyQzE1RTUwNUQtNjI1IiwiY29udGVudElEVHlwZSI6IkNvbnRlbnRSZXNlcnZlSUQiLCJjb250ZW50RXhwaXJhdGlvblVUQyI6IjIwMjQtMDctMDlUMTU6NTU6NDYuMDY5MDg0N1oiLCJVUkxFeHBpcmF0aW9uVVRDIjoiMjAyNC0wNy0wOFQxNjo1NTo0Ni4yMjU1ODA4WiIsInNvdXJjZUludGVyYWN0aW9uSUQiOiI5OTgyNzk1NDU4NTMuODM5LjgwNWE5ZDg1LWMwMmUtNDcyMy04ODkwLTc3MjdmODM3MGNlOSIsInNvdXJjZUFQSVVzZXIiOiJvZHBsdXRvIiwiZm9ybWF0U3BlY2lmaWMiOnsiQlVJRCI6Ijk4NDNhNmFhOTM3MzE5Y2UzNDViOTM0ZTE0NWIyYmU2IiwicGF0aCI6Ins3RUNBM0M3QS03NTE0LTQ2QzYtQUM3RS1GN0YyQzE1RTUwNUR9Rm10NDI1LVBhcnQwNi5tcDMifX0%3D&s=356aef2641bcf856597b6a14662a86d79cf526272dc95bb9229c215d677f5a45\",\"readingOrderItemOffsetMilliseconds\":40961},\"annotationId\":\"\",\"timeStamp\":\"2024-07-08T16:32:19Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Overdrive%20ID/7eca3c7a-7514-46c6-ac7e-f7f2c15e505d" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17320", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-08T15:59:35Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-08T15:59:35Z\",\"annotationId\":\"\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":6769,\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"https:\\/\\/ofsdirect.api.overdrive.com\\/contentfile?body=eyJjb250ZW50SUQiOiI3RUNBM0M3QS03NTE0LTQ2QzYtQUM3RS1GN0YyQzE1RTUwNUQtNjI1IiwiY29udGVudElEVHlwZSI6IkNvbnRlbnRSZXNlcnZlSUQiLCJjb250ZW50RXhwaXJhdGlvblVUQyI6IjIwMjQtMDctMDlUMTU6NTU6NDYuMDY5MDg0N1oiLCJVUkxFeHBpcmF0aW9uVVRDIjoiMjAyNC0wNy0wOFQxNjo1NTo0Ni4yMjU1ODA4WiIsInNvdXJjZUludGVyYWN0aW9uSUQiOiI5OTgyNzk1NDU4NTMuODM5LjgwNWE5ZDg1LWMwMmUtNDcyMy04ODkwLTc3MjdmODM3MGNlOSIsInNvdXJjZUFQSVVzZXIiOiJvZHBsdXRvIiwiZm9ybWF0U3BlY2lmaWMiOnsiQlVJRCI6Ijk4NDNhNmFhOTM3MzE5Y2UzNDViOTM0ZTE0NWIyYmU2IiwicGF0aCI6Ins3RUNBM0M3QS03NTE0LTQ2QzYtQUM3RS1GN0YyQzE1RTUwNUR9Rm10NDI1LVBhcnQwNi5tcDMifX0%3D&s=356aef2641bcf856597b6a14662a86d79cf526272dc95bb9229c215d677f5a45\"},\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Overdrive%20ID/7eca3c7a-7514-46c6-ac7e-f7f2c15e505d" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17313", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-08T13:00:58Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"urn:org.thepalaceproject:findaway:nil:1\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":225000},\"annotationId\":\"\",\"timeStamp\":\"2024-07-08T13:00:58Z\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Bibliotheca%20ID/6g3dfr9" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17309", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-08T12:54:29Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-08T12:54:29Z\",\"locator\":{\"@version\":2,\"readingOrderItemOffsetMilliseconds\":460159,\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/0ef692a4-5e69-473f-b3d0-3a352b83ea73\\/assets\\/content\\/SID-0000004245874_0004_64k.mp3\",\"@type\":\"LocatorAudioBookTime\"}}" + }, + "source": "urn:uuid:0ef692a4-5e69-473f-b3d0-3a352b83ea73" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17312", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter: 3", + "http://librarysimplified.org/terms/device": "urn:uuid:0a681a0a-01f7-4fa1-a0d3-e1f92f2e26c3", + "http://librarysimplified.org/terms/time": "2024-07-08T12:30:46Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"chapter\":2,\"startOffset\":0,\"time\":272547,\"part\":0,\"title\":\"Chapter: 3\",\"audiobookID\":\"urn:uuid:0ef692a4-5e69-473f-b3d0-3a352b83ea73\",\"duration\":947565}" + }, + "source": "urn:uuid:0ef692a4-5e69-473f-b3d0-3a352b83ea73" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17311", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-08T12:27:37Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":283661,\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/0ef692a4-5e69-473f-b3d0-3a352b83ea73\\/assets\\/content\\/SID-0000004245874_0004_64k.mp3\",\"@version\":2},\"timeStamp\":\"2024-07-08T12:27:37Z\",\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\"}" + }, + "source": "urn:uuid:0ef692a4-5e69-473f-b3d0-3a352b83ea73" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17308", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Prologue: Ghosts", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-05T15:37:36Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorHrefProgression\",\"cssSelector\":\"\",\"href\":\"/OEBPS/Text/prologue.xhtml\",\"position\":26,\"progressWithinBook\":0.022935779816513763,\"progressWithinChapter\":0.13043478260869565,\"title\":\"Prologue: Ghosts\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Bibliotheca%20ID/5r6ya89" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17307", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-05T14:28:28Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"readingOrderItemOffsetMilliseconds\":274539,\"readingOrderItem\":\"https:\\/\\/app.unlimitedlistens.com\\/api\\/track\\/6981cc77-9399-47ea-923b-64d31b739ddb\",\"@version\":2,\"@type\":\"LocatorAudioBookTime\"},\"annotationId\":\"\",\"timeStamp\":\"2024-07-05T14:28:28Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781646894550" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17306", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Track 0", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-04T14:20:26Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"part\":0,\"audiobookID\":\"246190\",\"duration\":24000,\"title\":\"Track 0\",\"time\":2000,\"startOffset\":0,\"annotationId\":\"\",\"lastSavedTimeStamp\":\"\",\"chapter\":0}" + }, + "source": "urn:librarysimplified.org/terms/id/Axis%20360%20ID/0012470892" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17299", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Track 0", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-04T14:20:07Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"chapter\":0,\"startOffset\":0,\"time\":4000,\"lastSavedTimeStamp\":\"\",\"duration\":61000,\"part\":0,\"title\":\"Track 0\",\"audiobookID\":\"13245\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Axis%20360%20ID/0012407518" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17273", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-03T17:13:25Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-03T17:13:25Z\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":121076,\"readingOrderItem\":\"1cace8f2-019d-4fdf-a749-bdc19f04ffd4.MP3.mp3\"},\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9798350407525" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17210", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-03T17:05:48Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"readingOrderItemOffsetMilliseconds\":4940,\"readingOrderItem\":\"b0443462-855c-4221-8675-97dd4224da1c.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2},\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-03T17:05:48Z\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17295", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:41:42Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/a3c4cbe4-e586-4dd7-bf06-e8d29d44ad31\\/assets\\/content\\/SID-0000003591892_0001_64k.mp3\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":11553},\"timeStamp\":\"2024-07-03T15:41:42Z\",\"annotationId\":\"\"}" + }, + "source": "urn:uuid:a3c4cbe4-e586-4dd7-bf06-e8d29d44ad31" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17305", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:27:53Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-07-03T15:27:53Z\",\"locator\":{\"@version\":2,\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/1f0b1b5c-5206-43bd-85b3-0b960c0fa1e5\\/assets\\/content\\/SID-0000004186229_0001_64k.mp3\",\"readingOrderItemOffsetMilliseconds\":165059,\"@type\":\"LocatorAudioBookTime\"},\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:uuid:1f0b1b5c-5206-43bd-85b3-0b960c0fa1e5" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17304", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:27:51Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/1f0b1b5c-5206-43bd-85b3-0b960c0fa1e5\\/assets\\/content\\/SID-0000004186229_0001_64k.mp3\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":133289,\"@type\":\"LocatorAudioBookTime\"},\"annotationId\":\"\",\"timeStamp\":\"2024-07-03T15:27:51Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:uuid:1f0b1b5c-5206-43bd-85b3-0b960c0fa1e5" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17303", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:19:35Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-03T15:19:35Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":15940,\"readingOrderItem\":\"fbbc31cd-55d0-480c-8b97-a1f6cedb70b4.MP3.mp3\",\"@version\":2}}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17302", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Track 1", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:11:03Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"time\":543000,\"startOffset\":0,\"audiobookID\":\"13245\",\"part\":0,\"duration\":1296000,\"@type\":\"LocatorAudioBookTime\",\"title\":\"Track 1\",\"chapter\":1,\"lastSavedTimeStamp\":\"2024-07-03T15:11:03Z\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Axis%20360%20ID/0012407518" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17301", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Track 1", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:11:00Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"part\":0,\"chapter\":1,\"title\":\"Track 1\",\"time\":183000,\"duration\":1296000,\"@type\":\"LocatorAudioBookTime\",\"audiobookID\":\"13245\",\"annotationId\":\"\",\"startOffset\":0,\"lastSavedTimeStamp\":\"2024-07-03T15:11:00Z\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Axis%20360%20ID/0012407518" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17300", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Track 0", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:10:57Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"lastSavedTimeStamp\":\"2024-07-03T15:10:57Z\",\"startOffset\":0,\"time\":4000,\"duration\":61000,\"title\":\"Track 0\",\"audiobookID\":\"13245\",\"part\":0,\"chapter\":0}" + }, + "source": "urn:librarysimplified.org/terms/id/Axis%20360%20ID/0012407518" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17298", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter: 1", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:09:34Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"audiobookID\":\"urn:uuid:a3c4cbe4-e586-4dd7-bf06-e8d29d44ad31\",\"chapter\":0,\"@type\":\"LocatorAudioBookTime\",\"startOffset\":0,\"time\":548000,\"part\":0,\"duration\":786000,\"lastSavedTimeStamp\":\"2024-07-03T15:09:34Z\",\"title\":\"Chapter: 1\",\"annotationId\":\"\"}" + }, + "source": "urn:uuid:a3c4cbe4-e586-4dd7-bf06-e8d29d44ad31" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17297", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter: 1", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:09:30Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"duration\":786000,\"chapter\":0,\"lastSavedTimeStamp\":\"2024-07-03T15:09:30Z\",\"audiobookID\":\"urn:uuid:a3c4cbe4-e586-4dd7-bf06-e8d29d44ad31\",\"part\":0,\"@type\":\"LocatorAudioBookTime\",\"startOffset\":0,\"time\":157000,\"title\":\"Chapter: 1\",\"annotationId\":\"\"}" + }, + "source": "urn:uuid:a3c4cbe4-e586-4dd7-bf06-e8d29d44ad31" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17296", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter: 1", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:09:23Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"time\":2000,\"lastSavedTimeStamp\":\"2024-07-03T15:09:23Z\",\"@type\":\"LocatorAudioBookTime\",\"startOffset\":0,\"title\":\"Chapter: 1\",\"audiobookID\":\"urn:uuid:a3c4cbe4-e586-4dd7-bf06-e8d29d44ad31\",\"duration\":786000,\"chapter\":0,\"part\":0}" + }, + "source": "urn:uuid:a3c4cbe4-e586-4dd7-bf06-e8d29d44ad31" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17294", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Part 1 Chapter 1", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:08:32Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"time\":827000,\"chapter\":1,\"annotationId\":\"\",\"audiobookID\":\"417143\",\"startOffset\":0,\"duration\":5478000,\"lastSavedTimeStamp\":\"2024-07-03T15:08:32Z\",\"@type\":\"LocatorAudioBookTime\",\"part\":1,\"title\":\"Part 1 Chapter 1\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Bibliotheca%20ID/6g3dfr9" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17293", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Part 1 Chapter 1", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:08:29Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"time\":525000,\"duration\":5478000,\"@type\":\"LocatorAudioBookTime\",\"startOffset\":0,\"chapter\":1,\"audiobookID\":\"417143\",\"title\":\"Part 1 Chapter 1\",\"part\":1,\"annotationId\":\"\",\"lastSavedTimeStamp\":\"2024-07-03T15:08:29Z\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Bibliotheca%20ID/6g3dfr9" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17292", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Introduction", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:08:22Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"duration\":31000,\"chapter\":0,\"lastSavedTimeStamp\":\"2024-07-03T15:08:22Z\",\"audiobookID\":\"417143\",\"part\":0,\"@type\":\"LocatorAudioBookTime\",\"startOffset\":0,\"time\":14000,\"title\":\"Introduction\",\"annotationId\":\"\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Bibliotheca%20ID/6g3dfr9" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17290", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter: 2", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:07:02Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"duration\":1099000,\"startOffset\":0,\"audiobookID\":\"urn:uuid:1f0b1b5c-5206-43bd-85b3-0b960c0fa1e5\",\"title\":\"Chapter: 2\",\"part\":0,\"time\":246000,\"annotationId\":\"\",\"chapter\":1,\"@type\":\"LocatorAudioBookTime\",\"lastSavedTimeStamp\":\"2024-07-03T15:07:02Z\"}" + }, + "source": "urn:uuid:1f0b1b5c-5206-43bd-85b3-0b960c0fa1e5" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17289", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter: 1", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:06:57Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"chapter\":0,\"title\":\"Chapter: 1\",\"startOffset\":0,\"duration\":358000,\"part\":0,\"time\":63000,\"audiobookID\":\"urn:uuid:1f0b1b5c-5206-43bd-85b3-0b960c0fa1e5\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"lastSavedTimeStamp\":\"2024-07-03T15:06:57Z\"}" + }, + "source": "urn:uuid:1f0b1b5c-5206-43bd-85b3-0b960c0fa1e5" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17288", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter: 1", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:06:55Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"chapter\":0,\"title\":\"Chapter: 1\",\"startOffset\":0,\"duration\":358000,\"part\":0,\"time\":32000,\"audiobookID\":\"urn:uuid:1f0b1b5c-5206-43bd-85b3-0b960c0fa1e5\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"lastSavedTimeStamp\":\"2024-07-03T15:06:55Z\"}" + }, + "source": "urn:uuid:1f0b1b5c-5206-43bd-85b3-0b960c0fa1e5" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17221", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 1", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:06:15Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"lastSavedTimeStamp\":\"\",\"chapter\":4,\"time\":751000,\"title\":\"Chapter 1\",\"startOffset\":506000,\"duration\":1425000,\"@type\":\"LocatorAudioBookTime\",\"part\":0,\"audiobookID\":\"urn:isbn:9798350400663\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9798350400663" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17286", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 1", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:05:53Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"duration\":1425000,\"lastSavedTimeStamp\":\"2024-07-03T15:05:53Z\",\"title\":\"Chapter 1\",\"audiobookID\":\"urn:isbn:9798350400663\",\"part\":0,\"time\":730000,\"@type\":\"LocatorAudioBookTime\",\"chapter\":4,\"startOffset\":506000,\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9798350400663" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17285", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Prologue", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:05:48Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"duration\":399000,\"lastSavedTimeStamp\":\"2024-07-03T15:05:48Z\",\"title\":\"Prologue\",\"audiobookID\":\"urn:isbn:9798350400663\",\"part\":0,\"time\":278000,\"@type\":\"LocatorAudioBookTime\",\"chapter\":2,\"startOffset\":105000,\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9798350400663" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17284", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Opening Credits", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:05:42Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"duration\":27000,\"@type\":\"LocatorAudioBookTime\",\"startOffset\":0,\"title\":\"Opening Credits\",\"audiobookID\":\"urn:isbn:9798350400663\",\"chapter\":0,\"part\":0,\"lastSavedTimeStamp\":\"2024-07-03T15:05:42Z\",\"time\":5000}" + }, + "source": "urn:isbn:9798350400663" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17283", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 3", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:04:02Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"part\":0,\"title\":\"Chapter 3\",\"lastSavedTimeStamp\":\"2024-07-03T15:04:02Z\",\"audiobookID\":\"urn:isbn:9780593402788\",\"annotationId\":\"\",\"startOffset\":0,\"@type\":\"LocatorAudioBookTime\",\"chapter\":2,\"time\":309000,\"duration\":2981000}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17282", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 3", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:03:57Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"duration\":2981000,\"lastSavedTimeStamp\":\"2024-07-03T15:03:57Z\",\"title\":\"Chapter 3\",\"annotationId\":\"\",\"startOffset\":0,\"chapter\":2,\"time\":6000,\"part\":0,\"audiobookID\":\"urn:isbn:9780593402788\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17281", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 1", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:03:53Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"part\":0,\"title\":\"Chapter 1\",\"lastSavedTimeStamp\":\"2024-07-03T15:03:53Z\",\"audiobookID\":\"urn:isbn:9780593402788\",\"annotationId\":\"\",\"startOffset\":0,\"@type\":\"LocatorAudioBookTime\",\"chapter\":0,\"time\":3000,\"duration\":16000}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17280", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter1", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:02:38Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"chapter\":4,\"part\":0,\"duration\":1894000,\"audiobookID\":\"urn:uuid:2e9fd133-99eb-450e-b301-0c9902ffef5b\",\"title\":\"Chapter1\",\"time\":124000,\"startOffset\":0,\"lastSavedTimeStamp\":\"2024-07-03T15:02:38Z\"}" + }, + "source": "urn:isbn:9781646894550" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17279", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Preface", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:02:32Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"lastSavedTimeStamp\":\"2024-07-03T15:02:32Z\",\"part\":0,\"startOffset\":0,\"@type\":\"LocatorAudioBookTime\",\"chapter\":2,\"time\":16000,\"duration\":69000,\"audiobookID\":\"urn:uuid:2e9fd133-99eb-450e-b301-0c9902ffef5b\",\"title\":\"Preface\"}" + }, + "source": "urn:isbn:9781646894550" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17278", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Opening Credits", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T15:02:24Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"audiobookID\":\"urn:uuid:2e9fd133-99eb-450e-b301-0c9902ffef5b\",\"time\":4000,\"annotationId\":\"\",\"lastSavedTimeStamp\":\"2024-07-03T15:02:24Z\",\"@type\":\"LocatorAudioBookTime\",\"startOffset\":0,\"part\":0,\"duration\":19000,\"title\":\"Opening Credits\",\"chapter\":0}" + }, + "source": "urn:isbn:9781646894550" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17277", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:59:31Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-03T14:59:31Z\",\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":20004,\"@version\":2,\"readingOrderItem\":\"eb2836dc-2269-4c3b-81ea-41a9e2b8933a.MP3.mp3\"}}" + }, + "source": "urn:isbn:9798350407525" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17276", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter Two", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:57:53Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"lastSavedTimeStamp\":\"2024-07-03T14:57:53Z\",\"title\":\"Chapter Two\",\"time\":1652000,\"audiobookID\":\"urn:isbn:9798350407525\",\"startOffset\":1000,\"duration\":3283000,\"chapter\":4,\"annotationId\":\"\",\"part\":0,\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9798350407525" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17275", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Prologue", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:57:46Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"duration\":966000,\"part\":0,\"title\":\"Prologue\",\"time\":261000,\"chapter\":1,\"startOffset\":17000,\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"audiobookID\":\"urn:isbn:9798350407525\",\"lastSavedTimeStamp\":\"2024-07-03T14:57:46Z\"}" + }, + "source": "urn:isbn:9798350407525" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17274", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Opening Credits", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:57:04Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"part\":0,\"startOffset\":0,\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"duration\":17000,\"chapter\":0,\"time\":5000,\"title\":\"Opening Credits\",\"audiobookID\":\"urn:isbn:9798350407525\",\"lastSavedTimeStamp\":\"2024-07-03T14:57:04Z\"}" + }, + "source": "urn:isbn:9798350407525" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17272", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:55:12Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"annotationId\":\"\",\"timeStamp\":\"2024-07-03T13:40:42Z\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":425960,\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"b0443462-855c-4221-8675-97dd4224da1c.MP3.mp3\"}},\"timeStamp\":\"2024-07-03T13:40:42Z\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17271", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:55:12Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"annotationId\":\"\",\"timeStamp\":\"2024-07-03T13:40:09Z\",\"locator\":{\"readingOrderItem\":\"bd2551e3-525e-4ce3-a1e2-ee70f224e922.MP3.mp3\",\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":5543123}},\"timeStamp\":\"2024-07-03T13:40:09Z\"}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17270", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:55:05Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"locator\":{\"locator\":{\"readingOrderItemOffsetMilliseconds\":425960,\"readingOrderItem\":\"b0443462-855c-4221-8675-97dd4224da1c.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2},\"timeStamp\":\"2024-07-03T13:40:42Z\",\"annotationId\":\"\"},\"timeStamp\":\"2024-07-03T13:40:42Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17269", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:55:05Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"annotationId\":\"\",\"timeStamp\":\"2024-07-03T13:40:09Z\",\"locator\":{\"readingOrderItem\":\"bd2551e3-525e-4ce3-a1e2-ee70f224e922.MP3.mp3\",\"readingOrderItemOffsetMilliseconds\":5543123,\"@version\":2,\"@type\":\"LocatorAudioBookTime\"}},\"timeStamp\":\"2024-07-03T13:40:09Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17268", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:54:38Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"annotationId\":\"\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":425960,\"@version\":2,\"readingOrderItem\":\"b0443462-855c-4221-8675-97dd4224da1c.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\"},\"timeStamp\":\"2024-07-03T13:40:42Z\"},\"timeStamp\":\"2024-07-03T13:40:42Z\"}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17267", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:54:37Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"locator\":{\"locator\":{\"readingOrderItem\":\"bd2551e3-525e-4ce3-a1e2-ee70f224e922.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":5543123},\"annotationId\":\"\",\"timeStamp\":\"2024-07-03T13:40:09Z\"},\"timeStamp\":\"2024-07-03T13:40:09Z\"}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17266", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:54:35Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"timeStamp\":\"2024-07-03T13:40:42Z\",\"locator\":{\"timeStamp\":\"2024-07-03T13:40:42Z\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":425960,\"@version\":2,\"readingOrderItem\":\"b0443462-855c-4221-8675-97dd4224da1c.MP3.mp3\"},\"annotationId\":\"\"}}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17265", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:54:34Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"locator\":{\"timeStamp\":\"2024-07-03T13:40:09Z\",\"annotationId\":\"\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":5543123,\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"bd2551e3-525e-4ce3-a1e2-ee70f224e922.MP3.mp3\"}},\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-03T13:40:09Z\"}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17264", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:54:33Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-03T13:40:42Z\",\"locator\":{\"timeStamp\":\"2024-07-03T13:40:42Z\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"b0443462-855c-4221-8675-97dd4224da1c.MP3.mp3\",\"readingOrderItemOffsetMilliseconds\":425960,\"@version\":2},\"annotationId\":\"\"}}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17263", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:54:32Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-03T13:40:09Z\",\"locator\":{\"annotationId\":\"\",\"locator\":{\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"bd2551e3-525e-4ce3-a1e2-ee70f224e922.MP3.mp3\",\"readingOrderItemOffsetMilliseconds\":5543123},\"timeStamp\":\"2024-07-03T13:40:09Z\"}}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17262", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Part One", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:53:12Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"title\":\"Part One\",\"duration\":7125000,\"startOffset\":0,\"part\":0,\"lastSavedTimeStamp\":\"2024-07-03T14:53:12Z\",\"@type\":\"LocatorAudioBookTime\",\"audiobookID\":\"urn:isbn:9781603932097\",\"annotationId\":\"\",\"chapter\":0,\"time\":5914000}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17261", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Part One", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:53:09Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"time\":2239000,\"duration\":7125000,\"@type\":\"LocatorAudioBookTime\",\"startOffset\":0,\"chapter\":0,\"audiobookID\":\"urn:isbn:9781603932097\",\"lastSavedTimeStamp\":\"2024-07-03T14:53:09Z\",\"title\":\"Part One\",\"part\":0}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17260", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Part One", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T14:53:05Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"title\":\"Part One\",\"chapter\":0,\"startOffset\":0,\"time\":512000,\"audiobookID\":\"urn:isbn:9781603932097\",\"lastSavedTimeStamp\":\"2024-07-03T14:53:05Z\",\"part\":0,\"duration\":7125000}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17259", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T13:41:04Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-03T13:40:42Z\",\"locator\":{\"annotationId\":\"\",\"timeStamp\":\"2024-07-03T13:40:42Z\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":425960,\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"b0443462-855c-4221-8675-97dd4224da1c.MP3.mp3\"}},\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17258", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T13:41:04Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"annotationId\":\"\",\"locator\":{\"readingOrderItem\":\"bd2551e3-525e-4ce3-a1e2-ee70f224e922.MP3.mp3\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":5543123,\"@type\":\"LocatorAudioBookTime\"},\"timeStamp\":\"2024-07-03T13:40:09Z\"},\"timeStamp\":\"2024-07-03T13:40:09Z\",\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17257", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Part Three", + "http://librarysimplified.org/terms/device": "urn:uuid:0a681a0a-01f7-4fa1-a0d3-e1f92f2e26c3", + "http://librarysimplified.org/terms/time": "2024-07-03T13:37:51Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"chapter\":2,\"startOffset\":2000,\"time\":399000,\"part\":0,\"title\":\"Part Three\",\"audiobookID\":\"urn:isbn:9781603932097\",\"duration\":6455000}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17256", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:bc133a6d-dbb5-4ad8-90f0-d6873c706da3", + "http://librarysimplified.org/terms/time": "2024-07-03T13:08:10Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItem\":\"b0443462-855c-4221-8675-97dd4224da1c.MP3.mp3\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":221055,\"@type\":\"LocatorAudioBookTime\"},\"timeStamp\":\"2024-07-03T13:08:10Z\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17255", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T13:01:01Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-03T13:01:01Z\",\"annotationId\":\"\",\"locator\":{\"@version\":2,\"readingOrderItem\":\"bd2551e3-525e-4ce3-a1e2-ee70f224e922.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":5434662}}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17254", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T13:00:55Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"@version\":2,\"readingOrderItem\":\"bd2551e3-525e-4ce3-a1e2-ee70f224e922.MP3.mp3\",\"readingOrderItemOffsetMilliseconds\":936301,\"@type\":\"LocatorAudioBookTime\"},\"timeStamp\":\"2024-07-03T13:00:55Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17253", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-03T13:00:41Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-03T13:00:41Z\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"bd2551e3-525e-4ce3-a1e2-ee70f224e922.MP3.mp3\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":27669},\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781603932097" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17214", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-02T18:36:33Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItem\":\"https:\\/\\/listen.audio.prod-northamerica.cantookaudio.com\\/item\\/da0aee1e-379f-43fc-aae9-6e0ed5e72dfb\\/cf2b5a3f-57c1-4823-9265-b0d49d3ba631.mp4\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":39141,\"@type\":\"LocatorAudioBookTime\"},\"timeStamp\":\"2024-07-02T18:36:33Z\"}" + }, + "source": "urn:isbn:9780063008595" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17220", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:43f2942e-9cbb-4fd2-a197-3d4cd47acc5f", + "http://librarysimplified.org/terms/time": "2024-07-02T13:40:47Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":16873,\"readingOrderItem\":\"7806a5c0-b9a9-4bfe-a5b2-3558878b3642.MP3.mp3\",\"@version\":2},\"timeStamp\":\"2024-07-02T13:40:47Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9798350410938" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17219", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-02T13:30:58Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":365896,\"readingOrderItem\":\"73274b16-b743-45d1-83cd-2596880aebc3.MP3.mp3\"},\"timeStamp\":\"2024-07-02T13:30:58Z\"}" + }, + "source": "urn:isbn:9798350410938" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17218", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-02T13:30:46Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"readingOrderItemOffsetMilliseconds\":31152,\"readingOrderItem\":\"14f7a3e4-debe-4a59-ba69-48934924b706.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2},\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-02T13:30:46Z\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9798350410938" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17213", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:1c8ff9f4-2460-47d3-a6b6-815296a3e137", + "http://librarysimplified.org/terms/time": "2024-07-02T12:36:41Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":1464,\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItem\":\"ed8aad7c-41c3-4bf4-96ca-dc3beeb4b4a2.MP3.mp3\"},\"timeStamp\":\"2024-07-02T12:36:41Z\"}" + }, + "source": "urn:isbn:9780062458537" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17212", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-02T12:26:45Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-07-02T12:26:45Z\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":0,\"readingOrderItem\":\"29b88050-6995-48af-829a-80040d02c8fb.MP3.mp3\",\"@version\":2,\"@type\":\"LocatorAudioBookTime\"},\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9798350417760" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17166", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 1", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-01T14:55:27Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"title\":\"Chapter 1\",\"chapter\":0,\"startOffset\":0,\"@type\":\"LocatorAudioBookTime\",\"audiobookID\":\"urn:isbn:9780593509616\",\"annotationId\":\"\",\"duration\":18000,\"part\":0,\"time\":2000,\"lastSavedTimeStamp\":\"\"}" + }, + "source": "urn:isbn:9780593509616" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17202", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 2", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-01T13:39:33Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"duration\":324000,\"lastSavedTimeStamp\":\"2024-07-01T13:39:33Z\",\"time\":168000,\"chapter\":1,\"audiobookID\":\"urn:isbn:9780593509616\",\"startOffset\":0,\"title\":\"Chapter 2\",\"part\":0,\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9780593509616" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17201", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-01T13:36:46Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-01T13:36:46Z\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"da8dddf2-64eb-4924-a446-0e8356aa1c51.MP3.mp3\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":482514},\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9780593509616" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17200", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-01T13:36:35Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"922555ee-a9da-47dd-9de8-c13717cb2a72.MP3.mp3\",\"readingOrderItemOffsetMilliseconds\":452440},\"annotationId\":\"\",\"timeStamp\":\"2024-07-01T13:36:35Z\"}" + }, + "source": "urn:isbn:9780593509616" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17199", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-07-01T13:36:24Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-07-01T13:36:24Z\",\"locator\":{\"@version\":2,\"readingOrderItem\":\"a8b5d1a9-f1c9-46d3-86cd-1d0020a4e7d4.MP3.mp3\",\"readingOrderItemOffsetMilliseconds\":5316,\"@type\":\"LocatorAudioBookTime\"},\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9780593509616" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17198", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:3f494ad2-cf15-4c06-837d-8e587d5b25dc", + "http://librarysimplified.org/terms/time": "2024-07-01T13:22:33Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"annotationId\":\"\",\"timeStamp\":\"2024-07-01T13:21:58Z\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"6f27e841-baf0-47ae-8a41-3885897fbd8f.MP3.mp3\",\"readingOrderItemOffsetMilliseconds\":210393,\"@version\":2}},\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"timeStamp\":\"2024-07-01T13:21:58Z\"}" + }, + "source": "urn:isbn:9780593509616" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17197", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:3f494ad2-cf15-4c06-837d-8e587d5b25dc", + "http://librarysimplified.org/terms/time": "2024-07-01T13:22:30Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-07-01T13:22:30Z\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":272423,\"readingOrderItem\":\"6f27e841-baf0-47ae-8a41-3885897fbd8f.MP3.mp3\"},\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9780593509616" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17196", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:3f494ad2-cf15-4c06-837d-8e587d5b25dc", + "http://librarysimplified.org/terms/time": "2024-07-01T13:21:33Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"readingOrderItemOffsetMilliseconds\":65491,\"readingOrderItem\":\"6f27e841-baf0-47ae-8a41-3885897fbd8f.MP3.mp3\",\"@version\":2,\"@type\":\"LocatorAudioBookTime\"},\"timeStamp\":\"2024-07-01T13:21:33Z\",\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9780593509616" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17190", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-28T14:35:05Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"locator\":{\"timeStamp\":\"2024-06-28T14:34:27Z\",\"annotationId\":\"\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":582651,\"readingOrderItem\":\"2760cbb9-edcd-459a-b74e-7878a8ba081d.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2}},\"timeStamp\":\"2024-06-28T14:34:27Z\"}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17189", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-28T14:35:05Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"locator\":{\"timeStamp\":\"2024-06-28T14:34:09Z\",\"annotationId\":\"\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":124704,\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"cab1d5ca-75f2-4b9d-99d9-5675f1117cb0.MP3.mp3\"}},\"timeStamp\":\"2024-06-28T14:34:09Z\"}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17188", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-28T14:35:05Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-06-28T14:33:48Z\",\"locator\":{\"timeStamp\":\"2024-06-28T14:33:48Z\",\"locator\":{\"@version\":2,\"readingOrderItemOffsetMilliseconds\":51657,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"fbbc31cd-55d0-480c-8b97-a1f6cedb70b4.MP3.mp3\"},\"annotationId\":\"\"},\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17187", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-28T14:35:01Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"readingOrderItemOffsetMilliseconds\":1894269,\"@version\":2,\"readingOrderItem\":\"2760cbb9-edcd-459a-b74e-7878a8ba081d.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\"},\"annotationId\":\"\",\"timeStamp\":\"2024-06-28T14:35:01Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17186", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-28T14:34:49Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-06-28T14:34:27Z\",\"locator\":{\"annotationId\":\"\",\"locator\":{\"readingOrderItem\":\"2760cbb9-edcd-459a-b74e-7878a8ba081d.MP3.mp3\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":582651,\"@type\":\"LocatorAudioBookTime\"},\"timeStamp\":\"2024-06-28T14:34:27Z\"},\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17185", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-28T14:34:48Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-06-28T14:34:09Z\",\"locator\":{\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":124704,\"readingOrderItem\":\"cab1d5ca-75f2-4b9d-99d9-5675f1117cb0.MP3.mp3\"},\"timeStamp\":\"2024-06-28T14:34:09Z\",\"annotationId\":\"\"},\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17184", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-28T14:34:48Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"timeStamp\":\"2024-06-28T14:33:48Z\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":51657,\"readingOrderItem\":\"fbbc31cd-55d0-480c-8b97-a1f6cedb70b4.MP3.mp3\",\"@version\":2},\"annotationId\":\"\"},\"annotationId\":\"\",\"timeStamp\":\"2024-06-28T14:33:48Z\"}" + }, + "source": "urn:isbn:9780593402788" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17183", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter1", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-28T13:59:44Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"part\":0,\"duration\":1900000,\"lastSavedTimeStamp\":\"\",\"audiobookID\":\"urn:uuid:c7242bb4-20cf-45a9-b0dd-f788c0372554\",\"annotationId\":\"\",\"title\":\"Chapter1\",\"chapter\":4,\"startOffset\":0,\"time\":306000}" + }, + "source": "urn:isbn:9781646893119" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17182", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Preface", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-28T13:51:33Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"time\":431000,\"chapter\":2,\"duration\":445000,\"title\":\"Preface\",\"lastSavedTimeStamp\":\"\",\"startOffset\":0,\"audiobookID\":\"urn:uuid:b51fd16c-23d5-4ba5-b3ac-00e2df647b4d\",\"annotationId\":\"\",\"part\":0,\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781646893713" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17181", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Introduction", + "http://librarysimplified.org/terms/device": "urn:uuid:0a681a0a-01f7-4fa1-a0d3-e1f92f2e26c3", + "http://librarysimplified.org/terms/time": "2024-06-28T13:43:43Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"chapter\":1,\"startOffset\":0,\"time\":388784,\"part\":0,\"title\":\"Introduction\",\"audiobookID\":\"urn:isbn:9781496459466\",\"duration\":1807386}" + }, + "source": "urn:isbn:9781496459466" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17180", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "Introduction", + "http://librarysimplified.org/terms/device": "urn:uuid:0a681a0a-01f7-4fa1-a0d3-e1f92f2e26c3", + "http://librarysimplified.org/terms/time": "2024-06-28T13:35:18Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"chapter\":2,\"startOffset\":0,\"time\":316751,\"part\":0,\"title\":\"Introduction\",\"audiobookID\":\"urn:isbn:9781646892884\",\"duration\":511537}" + }, + "source": "urn:isbn:9781646892884" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17118", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-28T13:24:16Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-06-28T13:24:16Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItem\":\"9781449815479_001_IN.mp3\",\"readingOrderItemOffsetMilliseconds\":0,\"@type\":\"LocatorAudioBookTime\",\"@version\":2}}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17176", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:c5633794-e5f9-4f1f-a0dd-7c3343872ff9", + "http://librarysimplified.org/terms/time": "2024-06-27T11:57:13Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-06-27T11:57:13Z\",\"locator\":{\"readingOrderItem\":\"9781449815479_017_C006.mp3\",\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":626515},\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17175", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:c5633794-e5f9-4f1f-a0dd-7c3343872ff9", + "http://librarysimplified.org/terms/time": "2024-06-27T11:56:59Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-06-27T11:56:59Z\",\"locator\":{\"readingOrderItem\":\"9781449815479_014_C004.mp3\",\"readingOrderItemOffsetMilliseconds\":207153,\"@version\":2,\"@type\":\"LocatorAudioBookTime\"},\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17171", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-26T17:10:01Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"readingOrderItemOffsetMilliseconds\":8161,\"readingOrderItem\":\"82430b9f-40a4-47c9-8ac3-414abd27d3e4.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2},\"annotationId\":\"\",\"timeStamp\":\"2024-06-26T17:10:01Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9780804127660" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17124", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-26T16:59:40Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-06-26T16:59:40Z\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@version\":2,\"readingOrderItem\":\"9b7a0286-927f-4f1b-8e6a-4038d110539e.MP3.mp3\",\"readingOrderItemOffsetMilliseconds\":1766,\"@type\":\"LocatorAudioBookTime\"},\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9780593788769" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17114", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-26T14:57:39Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-06-26T14:57:39Z\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":6472,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/d97c185a-8f90-4227-8521-59d36faa85a2\\/assets\\/content\\/SID-0000003606386_0053_64k.mp3\",\"@version\":2},\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:uuid:d97c185a-8f90-4227-8521-59d36faa85a2" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17168", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 1", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-26T14:00:04Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"time\":17000,\"audiobookID\":\"urn:isbn:9781456129866\",\"startOffset\":0,\"title\":\"Chapter 1\",\"lastSavedTimeStamp\":\"2024-06-26T14:00:04Z\",\"part\":0,\"duration\":18000,\"chapter\":0,\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17165", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:e26d01d3-4979-4f05-ad0f-e3adeda74aae", + "http://librarysimplified.org/terms/time": "2024-06-26T11:57:45Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"readingOrderItemOffsetMilliseconds\":7048,\"@version\":2,\"readingOrderItem\":\"9781449815479_015_C005.mp3\",\"@type\":\"LocatorAudioBookTime\"},\"timeStamp\":\"2024-06-26T11:57:45Z\",\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17164", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:e26d01d3-4979-4f05-ad0f-e3adeda74aae", + "http://librarysimplified.org/terms/time": "2024-06-26T11:57:07Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-06-26T11:57:07Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":7066,\"readingOrderItem\":\"9781449815479_002_IN.mp3\",\"@version\":2}}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17163", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 7", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:42:08Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"lastSavedTimeStamp\":\"2024-06-25T15:42:08Z\",\"annotationId\":\"\",\"part\":0,\"time\":425000,\"title\":\"Chapter 7\",\"startOffset\":0,\"@type\":\"LocatorAudioBookTime\",\"chapter\":6,\"duration\":1805000,\"audiobookID\":\"urn:isbn:9781456129866\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17162", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 7", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:42:03Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"startOffset\":0,\"duration\":1805000,\"title\":\"Chapter 7\",\"chapter\":6,\"@type\":\"LocatorAudioBookTime\",\"audiobookID\":\"urn:isbn:9781456129866\",\"annotationId\":\"\",\"lastSavedTimeStamp\":\"2024-06-25T15:42:03Z\",\"time\":91000,\"part\":0}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17161", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 4", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:41:55Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"title\":\"Chapter 4\",\"time\":272000,\"@type\":\"LocatorAudioBookTime\",\"startOffset\":0,\"annotationId\":\"\",\"lastSavedTimeStamp\":\"2024-06-25T15:41:55Z\",\"chapter\":3,\"audiobookID\":\"urn:isbn:9781456129866\",\"part\":0,\"duration\":1692000}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17160", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 2", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:41:47Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"lastSavedTimeStamp\":\"2024-06-25T15:41:47Z\",\"annotationId\":\"\",\"part\":0,\"time\":13000,\"title\":\"Chapter 2\",\"startOffset\":0,\"@type\":\"LocatorAudioBookTime\",\"chapter\":1,\"duration\":217000,\"audiobookID\":\"urn:isbn:9781456129866\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17159", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:21:30Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:40:52Z\",\"locator\":{\"timeStamp\":\"2024-06-25T14:40:52Z\",\"annotationId\":\"\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":164309,\"@version\":2,\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"@type\":\"LocatorAudioBookTime\"}},\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17158", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:21:30Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"timeStamp\":\"2024-06-25T14:40:40Z\",\"locator\":{\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"readingOrderItemOffsetMilliseconds\":153237,\"@version\":2,\"@type\":\"LocatorAudioBookTime\"},\"annotationId\":\"\"},\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:40:40Z\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17157", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:21:29Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-06-25T14:40:12Z\",\"locator\":{\"timeStamp\":\"2024-06-25T14:40:12Z\",\"annotationId\":\"\",\"locator\":{\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":125099}}}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17156", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:21:29Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"locator\":{\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:39:24Z\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":190539,\"@version\":2,\"readingOrderItem\":\"9781449815479_003_IN.mp3\",\"@type\":\"LocatorAudioBookTime\"}},\"timeStamp\":\"2024-06-25T14:39:24Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17155", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:21:25Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":216658,\"@version\":2,\"readingOrderItem\":\"9781449815479_003_IN.mp3\",\"@type\":\"LocatorAudioBookTime\"},\"timeStamp\":\"2024-06-25T15:21:25Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17147", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:20:56Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"locator\":{\"readingOrderItemOffsetMilliseconds\":164309,\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"9781449815479_004_C001.mp3\"},\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:40:52Z\"},\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-06-25T14:40:52Z\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17154", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:20:55Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:40:40Z\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":153237,\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"@version\":2},\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:40:40Z\"}}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17153", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:20:55Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:40:12Z\",\"locator\":{\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"readingOrderItemOffsetMilliseconds\":125099,\"@version\":2,\"@type\":\"LocatorAudioBookTime\"}},\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-06-25T14:40:12Z\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17152", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:20:54Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:39:24Z\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":190539,\"@version\":2,\"readingOrderItem\":\"9781449815479_003_IN.mp3\",\"@type\":\"LocatorAudioBookTime\"}},\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:39:24Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17151", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:20:47Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-06-25T14:40:52Z\",\"locator\":{\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:40:52Z\",\"locator\":{\"@version\":2,\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"readingOrderItemOffsetMilliseconds\":164309,\"@type\":\"LocatorAudioBookTime\"}},\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17150", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:20:47Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-06-25T14:40:40Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":153237},\"timeStamp\":\"2024-06-25T14:40:40Z\",\"annotationId\":\"\"}}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17149", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:20:46Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-06-25T14:40:12Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"timeStamp\":\"2024-06-25T14:40:12Z\",\"locator\":{\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"readingOrderItemOffsetMilliseconds\":125099},\"annotationId\":\"\"}}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17148", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:20:46Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\",\"locator\":{\"locator\":{\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":190539,\"readingOrderItem\":\"9781449815479_003_IN.mp3\"},\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:39:24Z\"},\"timeStamp\":\"2024-06-25T14:39:24Z\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17146", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:19:49Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-06-25T14:40:40Z\",\"locator\":{\"locator\":{\"@version\":2,\"readingOrderItemOffsetMilliseconds\":153237,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"9781449815479_004_C001.mp3\"},\"timeStamp\":\"2024-06-25T14:40:40Z\",\"annotationId\":\"\"},\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17145", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:19:48Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"locator\":{\"annotationId\":\"\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":125099,\"@version\":2,\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"@type\":\"LocatorAudioBookTime\"},\"timeStamp\":\"2024-06-25T14:40:12Z\"},\"timeStamp\":\"2024-06-25T14:40:12Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17144", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:19:48Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:39:24Z\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"annotationId\":\"\",\"locator\":{\"@version\":2,\"readingOrderItemOffsetMilliseconds\":190539,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"9781449815479_003_IN.mp3\"},\"timeStamp\":\"2024-06-25T14:39:24Z\"}}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17143", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:18:12Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"locator\":{\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"readingOrderItemOffsetMilliseconds\":164309,\"@type\":\"LocatorAudioBookTime\",\"@version\":2},\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:40:52Z\"},\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:40:52Z\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17142", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:18:11Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:40:40Z\",\"locator\":{\"locator\":{\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"readingOrderItemOffsetMilliseconds\":153237,\"@version\":2,\"@type\":\"LocatorAudioBookTime\"},\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:40:40Z\"},\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17141", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:18:11Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-06-25T14:40:12Z\",\"locator\":{\"locator\":{\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":125099,\"@type\":\"LocatorAudioBookTime\"},\"timeStamp\":\"2024-06-25T14:40:12Z\",\"annotationId\":\"\"}}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17140", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:18:11Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"timeStamp\":\"2024-06-25T14:39:24Z\",\"locator\":{\"@version\":2,\"readingOrderItemOffsetMilliseconds\":190539,\"readingOrderItem\":\"9781449815479_003_IN.mp3\",\"@type\":\"LocatorAudioBookTime\"},\"annotationId\":\"\"},\"timeStamp\":\"2024-06-25T14:39:24Z\",\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17139", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:18:09Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"annotationId\":\"\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":164309},\"timeStamp\":\"2024-06-25T14:40:52Z\"},\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-06-25T14:40:52Z\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17138", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:18:09Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:40:40Z\",\"locator\":{\"locator\":{\"readingOrderItemOffsetMilliseconds\":153237,\"@version\":2,\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"@type\":\"LocatorAudioBookTime\"},\"timeStamp\":\"2024-06-25T14:40:40Z\",\"annotationId\":\"\"},\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17137", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:18:09Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"annotationId\":\"\",\"locator\":{\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"readingOrderItemOffsetMilliseconds\":125099},\"timeStamp\":\"2024-06-25T14:40:12Z\"},\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-06-25T14:40:12Z\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17136", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:18:08Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-06-25T14:39:24Z\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"timeStamp\":\"2024-06-25T14:39:24Z\",\"annotationId\":\"\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"9781449815479_003_IN.mp3\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":190539}},\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17135", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:17:49Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"timeStamp\":\"2024-06-25T14:40:52Z\",\"annotationId\":\"\",\"locator\":{\"@version\":2,\"readingOrderItemOffsetMilliseconds\":164309,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"9781449815479_004_C001.mp3\"}},\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-06-25T14:40:52Z\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17134", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:17:49Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-06-25T14:40:40Z\",\"locator\":{\"timeStamp\":\"2024-06-25T14:40:40Z\",\"annotationId\":\"\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":153237,\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"@version\":2,\"@type\":\"LocatorAudioBookTime\"}},\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17133", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:17:49Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"timeStamp\":\"2024-06-25T14:40:12Z\",\"annotationId\":\"\",\"locator\":{\"readingOrderItem\":\"9781449815479_004_C001.mp3\",\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":125099,\"@version\":2}},\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-06-25T14:40:12Z\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17132", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T15:17:49Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:39:24Z\",\"locator\":{\"readingOrderItem\":\"9781449815479_003_IN.mp3\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":190539}},\"annotationId\":\"\",\"timeStamp\":\"2024-06-25T14:39:24Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17131", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 8", + "http://librarysimplified.org/terms/device": "urn:uuid:0a681a0a-01f7-4fa1-a0d3-e1f92f2e26c3", + "http://librarysimplified.org/terms/time": "2024-06-25T13:58:37Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"chapter\":7,\"startOffset\":0,\"time\":150000,\"part\":0,\"title\":\"Chapter 8\",\"audiobookID\":\"urn:isbn:9781456129866\",\"duration\":1402000}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17130", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 6", + "http://librarysimplified.org/terms/device": "urn:uuid:0a681a0a-01f7-4fa1-a0d3-e1f92f2e26c3", + "http://librarysimplified.org/terms/time": "2024-06-25T13:58:30Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"chapter\":5,\"startOffset\":0,\"time\":91000,\"part\":0,\"title\":\"Chapter 6\",\"audiobookID\":\"urn:isbn:9781456129866\",\"duration\":2190000}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17129", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "Chapter 5", + "http://librarysimplified.org/terms/device": "urn:uuid:0a681a0a-01f7-4fa1-a0d3-e1f92f2e26c3", + "http://librarysimplified.org/terms/time": "2024-06-25T13:58:18Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"chapter\":4,\"startOffset\":0,\"time\":12000,\"part\":0,\"title\":\"Chapter 5\",\"audiobookID\":\"urn:isbn:9781456129866\",\"duration\":2176000}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17128", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:4fad2327-2674-4786-95d8-cd1cbdba8e78", + "http://librarysimplified.org/terms/time": "2024-06-25T13:48:06Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItem\":\"9781449815479_006_C002.mp3\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItemOffsetMilliseconds\":33603},\"timeStamp\":\"2024-06-25T13:48:06Z\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17126", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T13:35:46Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-06-25T13:35:46Z\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"9781449815479_002_IN.mp3\",\"readingOrderItemOffsetMilliseconds\":121798,\"@version\":2}}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17125", + "type": "Annotation", + "motivation": "http://www.w3.org/ns/oa#bookmarking", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-25T13:33:38Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"2024-06-25T13:33:38Z\",\"annotationId\":\"\",\"locator\":{\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"9781449815479_001_IN.mp3\",\"readingOrderItemOffsetMilliseconds\":11344},\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781456129866" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17120", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-24T17:06:15Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-06-24T17:06:15Z\",\"locator\":{\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/c748a441-f088-433f-a643-d69ba1b001ab\\/assets\\/content\\/SID-0000003618217_0001_64k.mp3\",\"readingOrderItemOffsetMilliseconds\":3999,\"@version\":2,\"@type\":\"LocatorAudioBookTime\"}}" + }, + "source": "urn:uuid:c748a441-f088-433f-a643-d69ba1b001ab" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17119", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-24T17:05:50Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"locator\":{\"@version\":2,\"readingOrderItemOffsetMilliseconds\":76789,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"f97b7ca6-48fc-46b7-a7a4-29be7d3ad6a3.MP3.mp3\"},\"timeStamp\":\"2024-06-24T17:05:50Z\",\"@type\":\"LocatorAudioBookTime\",\"annotationId\":\"\"}" + }, + "source": "urn:isbn:9781488207822" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17111", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-24T16:57:30Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-06-24T16:57:30Z\",\"locator\":{\"readingOrderItem\":\"urn:org.thepalaceproject:findaway:nil:1\",\"readingOrderItemOffsetMilliseconds\":3000,\"@version\":2,\"@type\":\"LocatorAudioBookTime\"},\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Bibliotheca%20ID/6kskkz9" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17108", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-24T16:52:54Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-06-24T16:52:54Z\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":147193,\"readingOrderItem\":\"https:\\/\\/app.unlimitedlistens.com\\/api\\/track\\/4ab8cc6a-1798-4a4b-bd19-b970679e66fd\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2}}" + }, + "source": "urn:isbn:9781496480552" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17105", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-24T16:13:59Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"timeStamp\":\"\",\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":696898,\"readingOrderItem\":\"a5ace2f2-4d9d-47db-bb12-5f530110d0f8.MP3.mp3\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2}}" + }, + "source": "urn:isbn:9781603935067" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17115", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-24T15:47:23Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-06-24T15:47:23Z\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":97698,\"readingOrderItem\":\"https:\\/\\/library.biblioboard.com\\/ext\\/api\\/media\\/a82d0abc-53b6-4734-9ad4-46217c0f71c9\\/assets\\/content\\/SID-0000003612938_0001_64k.mp3\",\"@type\":\"LocatorAudioBookTime\",\"@version\":2},\"annotationId\":\"\"}" + }, + "source": "urn:uuid:a82d0abc-53b6-4734-9ad4-46217c0f71c9" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17112", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-24T15:46:33Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"timeStamp\":\"2024-06-24T15:46:33Z\",\"@type\":\"LocatorAudioBookTime\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":85000,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItem\":\"urn:org.thepalaceproject:findaway:nil:1\",\"@version\":2}}" + }, + "source": "urn:librarysimplified.org/terms/id/Axis%20360%20ID/0012407933" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17117", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-24T15:34:45Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-06-24T15:34:45Z\",\"annotationId\":\"\",\"locator\":{\"@version\":2,\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":29538,\"readingOrderItem\":\"https:\\/\\/ofsdirect.api.overdrive.com\\/contentfile?body=eyJjb250ZW50SUQiOiI2MTk2M0ZBRC0xRjIyLTQ0MkItOUI4MC00RkVCMTZGNjAyRjgtNjI1IiwiY29udGVudElEVHlwZSI6IkNvbnRlbnRSZXNlcnZlSUQiLCJjb250ZW50RXhwaXJhdGlvblVUQyI6IjIwMjQtMDYtMjVUMTU6MzQ6MDguOTY3MTk1OVoiLCJVUkxFeHBpcmF0aW9uVVRDIjoiMjAyNC0wNi0yNFQxNjozNDowOS4wNzI3OTczWiIsInNvdXJjZUludGVyYWN0aW9uSUQiOiI5OTgyODA3NTY3NTAuOTkwLmYxYTQ4YzI5LTJmNmItNGM1NC1hMTk0LTlhNWNlZTc0OGI4NCIsInNvdXJjZUFQSVVzZXIiOiJvZHBsdXRvIiwiZm9ybWF0U3BlY2lmaWMiOnsiQlVJRCI6Ijc3NTlkMTllYmFjMjQ0MWU1ZTdlMzVmMGI3NTNhODY1IiwicGF0aCI6Ins2MTk2M0ZBRC0xRjIyLTQ0MkItOUI4MC00RkVCMTZGNjAyRjh9Rm10NDI1LVBhcnQwMS5tcDMifX0%3D&s=c6e5bb051da85b8b603a6b9179b9bd65ceff3b688a8868a391d6419d0c424171\"}}" + }, + "source": "urn:librarysimplified.org/terms/id/Overdrive%20ID/61963fad-1f22-442b-9b80-4feb16f602f8" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17113", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-24T15:29:45Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"locator\":{\"@version\":2,\"readingOrderItem\":\"urn:org.thepalaceproject:findaway:nil:0\",\"@type\":\"LocatorAudioBookTime\",\"readingOrderItemOffsetMilliseconds\":4000},\"timeStamp\":\"2024-06-24T15:29:45Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:librarysimplified.org/terms/id/Axis%20360%20ID/0012407911" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17110", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-24T15:26:09Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"@type\":\"LocatorAudioBookTime\",\"timeStamp\":\"2024-06-24T15:26:09Z\",\"locator\":{\"readingOrderItemOffsetMilliseconds\":7084,\"@type\":\"LocatorAudioBookTime\",\"@version\":2,\"readingOrderItem\":\"https:\\/\\/app.unlimitedlistens.com\\/api\\/track\\/5b720c85-a774-4f7d-8a8e-7eb48c776b38\"}}" + }, + "source": "urn:isbn:9780882641928" + } + }, + { + "id": "https://gorgon.staging.palaceproject.io/a1qa-test/annotations/17109", + "type": "Annotation", + "motivation": "http://librarysimplified.org/terms/annotation/idling", + "body": { + "http://librarysimplified.org/terms/chapter": "", + "http://librarysimplified.org/terms/device": "urn:uuid:899004aa-9a50-4a92-95be-0d6b5e92be47", + "http://librarysimplified.org/terms/time": "2024-06-24T15:23:25Z" + }, + "target": { + "selector": { + "type": "FragmentSelector", + "value": "{\"annotationId\":\"\",\"locator\":{\"readingOrderItem\":\"https:\\/\\/app.unlimitedlistens.com\\/api\\/track\\/e21114f6-551d-4377-b314-44f319b7fb45\",\"readingOrderItemOffsetMilliseconds\":9801,\"@type\":\"LocatorAudioBookTime\",\"@version\":2},\"timeStamp\":\"2024-06-24T15:23:25Z\",\"@type\":\"LocatorAudioBookTime\"}" + }, + "source": "urn:isbn:9781915705860" + } + } + ] + } +} \ No newline at end of file diff --git a/simplified-tests/src/test/resources/org/nypl/simplified/tests/bookmarks/example-locator.json b/simplified-tests/src/test/resources/org/nypl/simplified/tests/bookmarks/example-locator.json new file mode 100644 index 000000000..1fd60c5fa --- /dev/null +++ b/simplified-tests/src/test/resources/org/nypl/simplified/tests/bookmarks/example-locator.json @@ -0,0 +1,8 @@ +{ + "annotationId": "", + "readingOrderItemOffsetMilliseconds": 99031, + "@type": "LocatorAudioBookTime", + "timeStamp": "2024-07-25T16:33:12Z", + "readingOrderItem": "58bf0094-3c8d-4ba6-9eaf-479ecd1c60ad.MP3.mp3", + "@version": 2 +} \ No newline at end of file diff --git a/simplified-ui-accounts/src/main/java/org/nypl/simplified/ui/accounts/AccountDetailFragment.kt b/simplified-ui-accounts/src/main/java/org/nypl/simplified/ui/accounts/AccountDetailFragment.kt index 1eb8b4b2d..d769d4bd0 100644 --- a/simplified-ui-accounts/src/main/java/org/nypl/simplified/ui/accounts/AccountDetailFragment.kt +++ b/simplified-ui-accounts/src/main/java/org/nypl/simplified/ui/accounts/AccountDetailFragment.kt @@ -445,7 +445,7 @@ class AccountDetailFragment : Fragment(R.layout.account) { try { this.startActivity(chosenIntent) } catch (e: Exception) { - this.logger.error("unable to start activity: ", e) + this.logger.debug("unable to start activity: ", e) val context = this.requireContext() MaterialAlertDialogBuilder(context) .setMessage(context.getString(R.string.accountReportFailed, supportUrl)) @@ -909,7 +909,7 @@ class AccountDetailFragment : Fragment(R.layout.account) { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resetPasswordURI.toString())) this.startActivity(intent) } catch (e: Exception) { - this.logger.error("unable to start activity: ", e) + this.logger.debug("unable to start activity: ", e) val context = this.requireContext() MaterialAlertDialogBuilder(context) .setMessage(context.getString(R.string.accountPasswordResetFailed, supportUrl)) @@ -967,7 +967,7 @@ class AccountDetailFragment : Fragment(R.layout.account) { } override fun onError(e: Exception) { - this@AccountDetailFragment.logger.error("failed to load authentication logo: ", e) + this@AccountDetailFragment.logger.debug("failed to load authentication logo: ", e) view.visibility = View.GONE } } diff --git a/simplified-ui-accounts/src/main/java/org/nypl/simplified/ui/accounts/AccountListRegistryViewModel.kt b/simplified-ui-accounts/src/main/java/org/nypl/simplified/ui/accounts/AccountListRegistryViewModel.kt index 2faee94da..9c314ee7a 100644 --- a/simplified-ui-accounts/src/main/java/org/nypl/simplified/ui/accounts/AccountListRegistryViewModel.kt +++ b/simplified-ui-accounts/src/main/java/org/nypl/simplified/ui/accounts/AccountListRegistryViewModel.kt @@ -118,7 +118,7 @@ class AccountListRegistryViewModel(private val locationManager: LocationManager) .showTestingLibraries ) } catch (e: Exception) { - this.logger.error("failed to refresh registry: ", e) + this.logger.debug("failed to refresh registry: ", e) } } } diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookDetailViewModel.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookDetailViewModel.kt index b1fea1905..a4674ec68 100644 --- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookDetailViewModel.kt +++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBookDetailViewModel.kt @@ -184,7 +184,7 @@ class CatalogBookDetailViewModel( false } } catch (e: Exception) { - this.logger.error("could not determine if the book could be revoked: ", e) + this.logger.debug("could not determine if the book could be revoked: ", e) false } @@ -232,7 +232,7 @@ class CatalogBookDetailViewModel( false } } catch (e: Exception) { - this.logger.error("could not determine if the book could be deleted: ", e) + this.logger.debug("could not determine if the book could be deleted: ", e) false } } diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBorrowViewModel.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBorrowViewModel.kt index 1619f5b7c..fc19eab3f 100644 --- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBorrowViewModel.kt +++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogBorrowViewModel.kt @@ -64,7 +64,7 @@ class CatalogBorrowViewModel( val isNotLoggedIn = account.loginState !is AccountLoggedIn requiresLogin && isNotLoggedIn } catch (e: Exception) { - this.logger.error("could not retrieve account: ", e) + this.logger.debug("could not retrieve account: ", e) false } } diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedFragment.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedFragment.kt index 3c41368d7..9a5d2eef3 100644 --- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedFragment.kt +++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedFragment.kt @@ -277,7 +277,7 @@ class CatalogFeedFragment : Fragment(R.layout.feed), AgeGateDialog.BirthYearSele this.logger.debug("logo: account provider was null") } } catch (e: Exception) { - this.logger.error("logo: unable to handle alternate link: ", e) + this.logger.debug("logo: unable to handle alternate link: ", e) } } @@ -621,7 +621,7 @@ class CatalogFeedFragment : Fragment(R.layout.feed), AgeGateDialog.BirthYearSele } } } catch (e: Exception) { - this.logger.error("Failed to open account picker dialog: ", e) + this.logger.debug("Failed to open account picker dialog: ", e) } } diff --git a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedViewModel.kt b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedViewModel.kt index 81e58f8cb..e5fe82a60 100644 --- a/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedViewModel.kt +++ b/simplified-ui-catalog/src/main/java/org/librarysimplified/ui/catalog/CatalogFeedViewModel.kt @@ -798,7 +798,7 @@ class CatalogFeedViewModel( ) } } catch (e: Exception) { - this.logger.error("could not log to analytics: ", e) + this.logger.debug("could not log to analytics: ", e) } } diff --git a/simplified-ui-navigation-tabs/src/main/java/org/librarysimplified/ui/navigation/tabs/BottomNavigators.kt b/simplified-ui-navigation-tabs/src/main/java/org/librarysimplified/ui/navigation/tabs/BottomNavigators.kt index e41276c30..ec5a8188d 100644 --- a/simplified-ui-navigation-tabs/src/main/java/org/librarysimplified/ui/navigation/tabs/BottomNavigators.kt +++ b/simplified-ui-navigation-tabs/src/main/java/org/librarysimplified/ui/navigation/tabs/BottomNavigators.kt @@ -99,7 +99,7 @@ object BottomNavigators { val profile = profilesController.profileCurrent() profile.preferences().dateOfBirth?.yearsOld(DateTime.now()) ?: 1 } catch (e: Exception) { - logger.error("could not retrieve profile age: ", e) + logger.debug("could not retrieve profile age: ", e) 1 } } @@ -114,7 +114,7 @@ object BottomNavigators { try { return profile.account(mostRecentId) } catch (e: Exception) { - logger.error("stale account: ", e) + logger.debug("stale account: ", e) } } diff --git a/simplified-ui-settings/src/main/java/org/nypl/simplified/ui/settings/SettingsCustomOPDSFragment.kt b/simplified-ui-settings/src/main/java/org/nypl/simplified/ui/settings/SettingsCustomOPDSFragment.kt index af67540c0..bd5a6462b 100644 --- a/simplified-ui-settings/src/main/java/org/nypl/simplified/ui/settings/SettingsCustomOPDSFragment.kt +++ b/simplified-ui-settings/src/main/java/org/nypl/simplified/ui/settings/SettingsCustomOPDSFragment.kt @@ -121,7 +121,7 @@ class SettingsCustomOPDSFragment : Fragment(R.layout.settings_custom_opds) { this.feedURL.setError(null, null) true } catch (e: Exception) { - this.logger.error("not a valid URI: ", e) + this.logger.debug("not a valid URI: ", e) this.feedURL.error = this.resources.getString(R.string.settingsCustomOPDSInvalidURI) false } diff --git a/simplified-ui-settings/src/main/java/org/nypl/simplified/ui/settings/SettingsDebugViewModel.kt b/simplified-ui-settings/src/main/java/org/nypl/simplified/ui/settings/SettingsDebugViewModel.kt index a32c72a37..37c4407ac 100644 --- a/simplified-ui-settings/src/main/java/org/nypl/simplified/ui/settings/SettingsDebugViewModel.kt +++ b/simplified-ui-settings/src/main/java/org/nypl/simplified/ui/settings/SettingsDebugViewModel.kt @@ -190,7 +190,7 @@ class SettingsDebugViewModel(application: Application) : AndroidViewModel(applic this.booksController.booksSync(account) } } catch (e: Exception) { - this.logger.error("ouch: ", e) + this.logger.debug("ouch: ", e) } } @@ -202,7 +202,7 @@ class SettingsDebugViewModel(application: Application) : AndroidViewModel(applic account.setPreferences(account.preferences.copy(announcementsAcknowledged = listOf())) } } catch (e: Exception) { - this.logger.error("could not forget announcements: ", e) + this.logger.debug("could not forget announcements: ", e) } } @@ -225,7 +225,7 @@ class SettingsDebugViewModel(application: Application) : AndroidViewModel(applic val activations = try { adeptFuture.get() } catch (e: Exception) { - this.logger.error("could not retrieve activations: ", e) + this.logger.debug("could not retrieve activations: ", e) emptyList() } activationsLive.postValue(activations) diff --git a/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/BootFragment.kt b/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/BootFragment.kt index 0e169a045..bce53e4c2 100644 --- a/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/BootFragment.kt +++ b/simplified-ui-splash/src/main/java/org/librarysimplified/ui/splash/BootFragment.kt @@ -91,7 +91,7 @@ class BootFragment : Fragment(R.layout.splash_boot) { } private fun onBootFailed(event: BootEvent.BootFailed) { - this.logger.error("boot failed: ", event.exception) + this.logger.debug("boot failed: ", event.exception) if (image.alpha > 0.0) { this.popImageView() } diff --git a/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookBookmarks.kt b/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookBookmarks.kt index ef1d9e464..0a5ebdc50 100644 --- a/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookBookmarks.kt +++ b/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookBookmarks.kt @@ -41,7 +41,7 @@ object AudioBookBookmarks { .bookmarkSyncAndLoad(accountID, bookID) .get(15L, TimeUnit.SECONDS) } catch (e: Exception) { - this.logger.error("could not load bookmarks: ", e) + this.logger.debug("could not load bookmarks: ", e) BookmarksForBook(bookID, null, emptyList()) } } diff --git a/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookPlayerActivity2.kt b/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookPlayerActivity2.kt index f91bcfbdd..8497af03d 100644 --- a/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookPlayerActivity2.kt +++ b/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookPlayerActivity2.kt @@ -37,6 +37,7 @@ import org.librarysimplified.audiobook.api.PlayerEvent.PlayerEventWithPosition.P import org.librarysimplified.audiobook.api.PlayerEvent.PlayerEventWithPosition.PlayerEventPlaybackWaitingForAction import org.librarysimplified.audiobook.api.PlayerUIThread import org.librarysimplified.audiobook.api.PlayerUserAgent +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID import org.librarysimplified.audiobook.views.PlayerBaseFragment import org.librarysimplified.audiobook.views.PlayerBookmarkModel import org.librarysimplified.audiobook.views.PlayerFragment @@ -48,6 +49,7 @@ import org.librarysimplified.audiobook.views.PlayerTOCFragment import org.librarysimplified.audiobook.views.PlayerViewCommand import org.librarysimplified.services.api.Services import org.nypl.simplified.bookmarks.api.BookmarkServiceType +import org.nypl.simplified.bookmarks.api.BookmarksForBook import org.nypl.simplified.books.covers.BookCoverProviderType import org.nypl.simplified.books.time.tracking.TimeTrackingServiceType import org.nypl.simplified.buildconfig.api.BuildConfigurationServiceType @@ -143,19 +145,19 @@ class AudioBookPlayerActivity2 : AppCompatActivity(R.layout.audio_book_player_ba try { PlayerModel.closeBookOrDismissError() } catch (e: Exception) { - this.logger.error("Failed to close book: ", e) + this.logger.debug("Failed to close book: ", e) } try { - this.timeTrackingService.stopTracking() + this.timeTrackingService.onBookClosed() } catch (e: Exception) { - this.logger.error("Failed to stop time tracking: ", e) + this.logger.debug("Failed to stop time tracking: ", e) } try { this.finish() } catch (e: Exception) { - this.logger.error("Failed to finish activity: ", e) + this.logger.debug("Failed to finish activity: ", e) } } @@ -217,7 +219,6 @@ class AudioBookPlayerActivity2 : AppCompatActivity(R.layout.audio_book_player_ba newBookmarks.addAll(PlayerBookmarkModel.bookmarks()) newBookmarks.removeIf { b -> b.position == playerBookmark.position } newBookmarks.add(0, playerBookmark) - PlayerBookmarkModel.setBookmarks(newBookmarks.toList()) } @@ -246,32 +247,34 @@ class AudioBookPlayerActivity2 : AppCompatActivity(R.layout.audio_book_player_ba val parameters = AudioBookViewerModel.parameters ?: return - val playerBookmark = event.bookmark - this.bookmarkService.bookmarkDelete( - accountID = parameters.accountID, - bookmark = AudioBookBookmarks.fromPlayerBookmark( + val playerBookmark = + event.bookmark + val appBookmark = + AudioBookBookmarks.fromPlayerBookmark( feedEntry = parameters.opdsEntry, deviceId = "null", source = playerBookmark - ), + ) + this.bookmarkService.bookmarkDelete( + accountID = parameters.accountID, + bookmark = appBookmark, ignoreRemoteFailures = true, ) + + val newBookmarks = arrayListOf() + newBookmarks.addAll(PlayerBookmarkModel.bookmarks()) + newBookmarks.remove(playerBookmark) + PlayerBookmarkModel.setBookmarks(newBookmarks.toList()) } is PlayerEventError -> { // Nothing yet... } - PlayerEventManifestUpdated -> { + is PlayerEventManifestUpdated -> { // Nothing yet... } } - - try { - this.timeTrackingService.onPlayerEventReceived(event) - } catch (e: Exception) { - this.logger.error("Failed to submit event to time tracking service: ", e) - } } @UiThread @@ -289,7 +292,7 @@ class AudioBookPlayerActivity2 : AppCompatActivity(R.layout.audio_book_player_ba } PlayerModelState.PlayerClosed -> { - this.timeTrackingService.stopTracking() + this.timeTrackingService.onBookClosed() this.switchFragment(AudioBookLoadingFragment2()) } @@ -302,12 +305,18 @@ class AudioBookPlayerActivity2 : AppCompatActivity(R.layout.audio_book_player_ba } is PlayerModelState.PlayerManifestOK -> { - this.timeTrackingService.startTimeTracking( - accountID = bookParameters.accountID, - bookId = bookParameters.opdsEntry.id, - libraryId = bookParameters.accountProviderID.toString(), - timeTrackingUri = bookParameters.opdsEntry.timeTrackingUri.getOrNull() - ) + val timeTrackingUri = bookParameters.opdsEntry.timeTrackingUri.getOrNull() + if (timeTrackingUri != null) { + this.logger.debug("Time tracking info will be sent to {}", timeTrackingUri) + this.timeTrackingService.onBookOpenedForTracking( + accountID = bookParameters.accountID, + bookId = PlayerPalaceID(bookParameters.opdsEntry.id), + libraryId = bookParameters.accountProviderID.toString(), + timeTrackingUri = timeTrackingUri + ) + } else { + this.logger.debug("Book has no time tracking URI. No time tracking will occur.") + } /* * XXX: This shouldn't really be a blocking call to get() @@ -320,12 +329,25 @@ class AudioBookPlayerActivity2 : AppCompatActivity(R.layout.audio_book_player_ba book = bookParameters.bookID ).get() - val bookmarksConverted = - bookmarks.bookmarks.mapNotNull(AudioBookBookmarks::toPlayerBookmark) - val bookmarkLastRead = - bookmarks.lastRead?.let { b -> AudioBookBookmarks.toPlayerBookmark(b) } + /* + * Again, the bookmark service should already have an up-to-date list, but it won't + * until we refactor the whole bookmark system. + */ - PlayerBookmarkModel.setBookmarks(bookmarksConverted) + this.bookmarkService.bookmarkSyncAndLoad( + accountID = bookParameters.accountID, + book = bookParameters.bookID + ).thenApply { arrivedBookmarks -> + PlayerUIThread.runOnUIThread { + this.logger.debug( + "{} bookmarks arrived from the server.", arrivedBookmarks.bookmarks.size + ) + this.assignBookmarks(arrivedBookmarks) + } + } + + val bookmarkLastRead = + this.assignBookmarks(bookmarks) val initialPosition = if (bookmarkLastRead != null) { @@ -361,6 +383,18 @@ class AudioBookPlayerActivity2 : AppCompatActivity(R.layout.audio_book_player_ba } } + private fun assignBookmarks( + bookmarks: BookmarksForBook + ): PlayerBookmark? { + val bookmarksConverted = + bookmarks.bookmarks.mapNotNull(AudioBookBookmarks::toPlayerBookmark) + val bookmarkLastRead = + bookmarks.lastRead?.let { b -> AudioBookBookmarks.toPlayerBookmark(b) } + + PlayerBookmarkModel.setBookmarks(bookmarksConverted) + return bookmarkLastRead + } + private fun loadCoverImage() { val parameters = AudioBookViewerModel.parameters ?: return @@ -551,16 +585,17 @@ class AudioBookPlayerActivity2 : AppCompatActivity(R.layout.audio_book_player_ba try { val book = PlayerModel.book() - for (e in book.downloadTasks) { + for (e in book?.downloadTasks ?: listOf()) { val status = e.status if (status is PlayerDownloadTaskStatus.Failed) { + val tasks = book?.downloadTasks ?: listOf() task.beginNewStep("Downloading ${e.playbackURI}...") task.currentStepFailed( message = status.message, errorCode = "error-download", exception = status.exception, extraMessages = - book.downloadTasks.filterIsInstance() + tasks.filterIsInstance() .map { s -> s.message }) } } diff --git a/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookPlayerParameters.kt b/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookPlayerParameters.kt index d5eb6d10f..a967a5ea2 100644 --- a/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookPlayerParameters.kt +++ b/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookPlayerParameters.kt @@ -1,11 +1,11 @@ package org.librarysimplified.viewer.audiobook +import java.io.Serializable +import java.net.URI import org.nypl.simplified.accounts.api.AccountID import org.nypl.simplified.books.api.BookDRMInformation import org.nypl.simplified.books.api.BookID import org.nypl.simplified.opds.core.OPDSAcquisitionFeedEntry -import java.io.Serializable -import java.net.URI /** * Parameters for the audio book player. @@ -47,5 +47,5 @@ class AudioBookPlayerParameters( * The DRM information for the book. */ - val drmInfo: BookDRMInformation + val drmInfo: BookDRMInformation, ) : Serializable diff --git a/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookViewer.kt b/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookViewer.kt index 5c0c1fee8..893d12b1f 100644 --- a/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookViewer.kt +++ b/simplified-viewer-audiobook/src/main/java/org/librarysimplified/viewer/audiobook/AudioBookViewer.kt @@ -6,7 +6,9 @@ import one.irradia.mime.api.MIMEType import org.librarysimplified.audiobook.api.PlayerUserAgent import org.librarysimplified.audiobook.feedbooks.FeedbooksPlayerExtension import org.librarysimplified.audiobook.license_check.spi.SingleLicenseCheckProviderType +import org.librarysimplified.audiobook.manifest.api.PlayerPalaceID import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfilled +import org.librarysimplified.audiobook.manifest_parser.api.ManifestUnparsed import org.librarysimplified.audiobook.manifest_parser.extension_spi.ManifestParserExtensionType import org.librarysimplified.audiobook.views.PlayerModel import org.librarysimplified.http.api.LSHTTPClientType @@ -20,7 +22,6 @@ import org.nypl.simplified.books.audio.AudioBookLink import org.nypl.simplified.books.audio.AudioBookManifestRequest import org.nypl.simplified.books.audio.AudioBookManifestStrategiesType import org.nypl.simplified.books.formats.api.StandardFormatNames -import org.nypl.simplified.networkconnectivity.api.NetworkConnectivityType import org.nypl.simplified.profiles.controller.api.ProfilesControllerType import org.nypl.simplified.viewer.spi.ViewerPreferences import org.nypl.simplified.viewer.spi.ViewerProviderType @@ -91,8 +92,6 @@ class AudioBookViewer : ViewerProviderType { Services.serviceDirectory() val httpClient = services.requireService(LSHTTPClientType::class.java) - val networkConnectivity = - services.requireService(NetworkConnectivityType::class.java) val strategies = services.requireService(AudioBookManifestStrategiesType::class.java) val profiles = @@ -113,6 +112,8 @@ class AudioBookViewer : ViewerProviderType { PlayerModel.bookAuthor = book.entry.authorsCommaSeparated PlayerModel.bookTitle = book.entry.title + val palaceID = + PlayerPalaceID(book.entry.id) val account = profiles.profileCurrent() .account(book.account) @@ -157,6 +158,7 @@ class AudioBookViewer : ViewerProviderType { cacheDir = activity.cacheDir, context = activity.application, licenseChecks = licenseChecks, + palaceID = palaceID, parserExtensions = parserExtensions, userAgent = userAgent, ) @@ -177,10 +179,10 @@ class AudioBookViewer : ViewerProviderType { if (licenseBytes != null && manifest != null) { PlayerModel.parseAndCheckLCPLicense( bookCredentials = bookCredentials, - licenseBytes = licenseBytes, - manifestBytes = manifest.manifestFile.readBytes(), cacheDir = activity.cacheDir, + licenseBytes = licenseBytes, licenseChecks = licenseChecks, + manifestUnparsed = ManifestUnparsed(palaceID, manifest.manifestFile.readBytes()), parserExtensions = parserExtensions, userAgent = userAgent, ) @@ -200,43 +202,46 @@ class AudioBookViewer : ViewerProviderType { if (manifestURI != null && manifestURI.isAbsolute) { val manifestRequest = AudioBookManifestRequest( + cacheDirectory = activity.cacheDir, + contentType = format.contentType, + credentials = accountCredentials, httpClient = httpClient, + palaceID = palaceID, + services = services, target = AudioBookLink.Manifest(manifestURI), - contentType = format.contentType, userAgent = userAgent, - cacheDirectory = activity.cacheDir, - credentials = accountCredentials, - services = services ) PlayerModel.downloadParseAndCheckManifest( sourceURI = manifestURI, - userAgent = userAgent, + bookCredentials = bookCredentials, cacheDir = activity.cacheDir, licenseChecks = licenseChecks, + palaceID = palaceID, parserExtensions = parserExtensions, strategy = strategies.createStrategy( context = activity.application, request = manifestRequest ).toManifestStrategy(), - bookCredentials = bookCredentials + userAgent = userAgent, ) this.openActivity(activity) return } PlayerModel.parseAndCheckManifest( + bookCredentials = bookCredentials, cacheDir = activity.cacheDir, + licenseChecks = licenseChecks, manifest = ManifestFulfilled( source = null, contentType = format.contentType, authorization = null, data = manifest.manifestFile.readBytes() ), - licenseChecks = licenseChecks, - userAgent = userAgent, + palaceID = palaceID, parserExtensions = parserExtensions, - bookCredentials = bookCredentials + userAgent = userAgent, ) this.openActivity(activity) } diff --git a/simplified-viewer-epub-readium2/src/main/java/org/librarysimplified/viewer/epub/readium2/Reader2Activity.kt b/simplified-viewer-epub-readium2/src/main/java/org/librarysimplified/viewer/epub/readium2/Reader2Activity.kt index 7e17cb9ce..188822ce3 100644 --- a/simplified-viewer-epub-readium2/src/main/java/org/librarysimplified/viewer/epub/readium2/Reader2Activity.kt +++ b/simplified-viewer-epub-readium2/src/main/java/org/librarysimplified/viewer/epub/readium2/Reader2Activity.kt @@ -149,7 +149,7 @@ class Reader2Activity : AppCompatActivity(R.layout.reader2) { .account(this.parameters.accountId) MDC.put(MDCKeys.ACCOUNT_PROVIDER_ID, this.account.provider.id.toString()) } catch (e: Exception) { - this.logger.error("Unable to locate account: ", e) + this.logger.debug("Unable to locate account: ", e) this.finish() return } diff --git a/simplified-viewer-epub-readium2/src/main/java/org/librarysimplified/viewer/epub/readium2/Reader2Bookmarks.kt b/simplified-viewer-epub-readium2/src/main/java/org/librarysimplified/viewer/epub/readium2/Reader2Bookmarks.kt index 743c65de4..b9257e7cf 100644 --- a/simplified-viewer-epub-readium2/src/main/java/org/librarysimplified/viewer/epub/readium2/Reader2Bookmarks.kt +++ b/simplified-viewer-epub-readium2/src/main/java/org/librarysimplified/viewer/epub/readium2/Reader2Bookmarks.kt @@ -38,7 +38,7 @@ object Reader2Bookmarks { .bookmarkSyncAndLoad(accountID, bookID) .get(15L, TimeUnit.SECONDS) } catch (e: Exception) { - this.logger.error("could not load bookmarks: ", e) + this.logger.debug("could not load bookmarks: ", e) BookmarksForBook(bookID, null, emptyList()) } } diff --git a/simplified-viewer-pdf-pdfjs/src/main/java/org/librarysimplified/viewer/pdf/pdfjs/PdfReaderActivity.kt b/simplified-viewer-pdf-pdfjs/src/main/java/org/librarysimplified/viewer/pdf/pdfjs/PdfReaderActivity.kt index c805ac5bf..999b25dc2 100644 --- a/simplified-viewer-pdf-pdfjs/src/main/java/org/librarysimplified/viewer/pdf/pdfjs/PdfReaderActivity.kt +++ b/simplified-viewer-pdf-pdfjs/src/main/java/org/librarysimplified/viewer/pdf/pdfjs/PdfReaderActivity.kt @@ -156,7 +156,7 @@ class PdfReaderActivity : AppCompatActivity() { this.documentPageIndex = 1 } } catch (e: Exception) { - this.log.error("Could not get lastReadLocation, defaulting to the 1st page", e) + this.log.debug("Could not get lastReadLocation, defaulting to the 1st page", e) } finally { this.completeReaderSetup( params = params, diff --git a/simplified-viewer-pdf-pdfjs/src/main/java/org/librarysimplified/viewer/pdf/pdfjs/PdfReaderBookmarks.kt b/simplified-viewer-pdf-pdfjs/src/main/java/org/librarysimplified/viewer/pdf/pdfjs/PdfReaderBookmarks.kt index d12903d76..97a00efd4 100644 --- a/simplified-viewer-pdf-pdfjs/src/main/java/org/librarysimplified/viewer/pdf/pdfjs/PdfReaderBookmarks.kt +++ b/simplified-viewer-pdf-pdfjs/src/main/java/org/librarysimplified/viewer/pdf/pdfjs/PdfReaderBookmarks.kt @@ -32,7 +32,7 @@ internal object PdfReaderBookmarks { .bookmarkSyncAndLoad(accountID, bookID) .get(15L, TimeUnit.SECONDS) } catch (e: Exception) { - this.logger.error("Could not load bookmarks: ", e) + this.logger.debug("Could not load bookmarks: ", e) BookmarksForBook(bookID, null, emptyList()) } } diff --git a/simplified-viewer-pdf-pdfjs/src/main/java/org/librarysimplified/viewer/pdf/pdfjs/PdfReaderDocument.kt b/simplified-viewer-pdf-pdfjs/src/main/java/org/librarysimplified/viewer/pdf/pdfjs/PdfReaderDocument.kt index 14365b4dc..91c21ed73 100644 --- a/simplified-viewer-pdf-pdfjs/src/main/java/org/librarysimplified/viewer/pdf/pdfjs/PdfReaderDocument.kt +++ b/simplified-viewer-pdf-pdfjs/src/main/java/org/librarysimplified/viewer/pdf/pdfjs/PdfReaderDocument.kt @@ -19,6 +19,10 @@ class PdfReaderDocument( override val title: String? get() = metadata.title + override fun close() { + // Nothing required + } + override val author: String? get() = metadata.author @@ -44,10 +48,10 @@ class PdfReaderDocument( core.renderPageBitmap(document, bitmap, 0, 0, 0, width, height, false) bitmap } catch (e: Exception) { - logger.error("Error rendering page: ", e) + logger.debug("Error rendering page: ", e) null } catch (e: OutOfMemoryError) { - logger.error("Error rendering page: ", e) + logger.debug("Error rendering page: ", e) null } } @@ -57,10 +61,6 @@ class PdfReaderDocument( core.getTableOfContents(document).map { it.toOutlineNode() } } - override suspend fun close() { - // do nothing - } - private fun PdfiumDocument.Bookmark.toOutlineNode(): PdfDocument.OutlineNode { return PdfDocument.OutlineNode( title = title, diff --git a/simplified-viewer-preview/src/main/java/org/librarysimplified/viewer/preview/BookPreviewActivity.kt b/simplified-viewer-preview/src/main/java/org/librarysimplified/viewer/preview/BookPreviewActivity.kt index 0abc9c537..5e57c8b39 100644 --- a/simplified-viewer-preview/src/main/java/org/librarysimplified/viewer/preview/BookPreviewActivity.kt +++ b/simplified-viewer-preview/src/main/java/org/librarysimplified/viewer/preview/BookPreviewActivity.kt @@ -29,7 +29,6 @@ import org.librarysimplified.r2.views.SR2ReaderViewEvent.SR2ReaderViewController import org.librarysimplified.r2.views.SR2SearchFragment import org.librarysimplified.r2.views.SR2TOCFragment import org.librarysimplified.services.api.Services -import org.librarysimplified.viewer.epub.readium2.Reader2LoadingFragment import org.librarysimplified.viewer.epub.readium2.Reader2Themes import org.nypl.drm.core.ContentProtectionProvider import org.nypl.simplified.accessibility.AccessibilityServiceType @@ -142,7 +141,13 @@ class BookPreviewActivity : AppCompatActivity(R.layout.activity_book_preview) { override fun onStart() { super.onStart() - this.switchFragment(Reader2LoadingFragment()) + this.switchFragment(BookPreviewNullFragment()) + + /* + * The book preview status observable is a BehaviorSubject, so subscribing to it will + * immediately cause the current views to be reconfigured to whatever is the most recent + * preview status. + */ this.subscriptions = CompositeDisposable() this.subscriptions.add( @@ -150,9 +155,6 @@ class BookPreviewActivity : AppCompatActivity(R.layout.activity_book_preview) { .observeOn(AndroidSchedulers.mainThread()) .subscribe(this::onNewBookPreviewStatus) ) - this.subscriptions.add(SR2ReaderModel.controllerEvents.subscribe(this::onControllerEvent)) - this.subscriptions.add(SR2ReaderModel.viewCommands.subscribe(this::onViewCommandReceived)) - this.subscriptions.add(SR2ReaderModel.viewEvents.subscribe(this::onViewEventReceived)) } override fun onStop() { @@ -234,8 +236,10 @@ class BookPreviewActivity : AppCompatActivity(R.layout.activity_book_preview) { this.uiThread.checkIsUIThread() } - private fun onNewBookPreviewStatus(previewStatus: BookPreviewStatus) { - when (previewStatus) { + private fun onNewBookPreviewStatus( + previewStatus: BookPreviewStatus + ) { + return when (previewStatus) { is BookPreviewStatus.HasPreview.Downloading -> { val received = previewStatus.currentTotalBytes val expected = previewStatus.expectedTotalBytes @@ -268,6 +272,9 @@ class BookPreviewActivity : AppCompatActivity(R.layout.activity_book_preview) { is BookPreviewStatus.HasPreview.Ready.BookPreview -> { this.logger.debug("Book preview") this.loadingProgress.isVisible = false + this.subscriptions.add(SR2ReaderModel.controllerEvents.subscribe(this::onControllerEvent)) + this.subscriptions.add(SR2ReaderModel.viewCommands.subscribe(this::onViewCommandReceived)) + this.subscriptions.add(SR2ReaderModel.viewEvents.subscribe(this::onViewEventReceived)) this.openReader(previewStatus.file) } @@ -298,7 +305,7 @@ class BookPreviewActivity : AppCompatActivity(R.layout.activity_book_preview) { val audiobookPreviewPlayer = BookPreviewAudiobookFragment.newInstance(file, this.feedEntry) this.supportFragmentManager.beginTransaction() - .add(R.id.preview_container, audiobookPreviewPlayer) + .replace(R.id.preview_container, audiobookPreviewPlayer) .commitAllowingStateLoss() } diff --git a/simplified-viewer-preview/src/main/java/org/librarysimplified/viewer/preview/BookPreviewNullFragment.kt b/simplified-viewer-preview/src/main/java/org/librarysimplified/viewer/preview/BookPreviewNullFragment.kt new file mode 100644 index 000000000..7daf7c341 --- /dev/null +++ b/simplified-viewer-preview/src/main/java/org/librarysimplified/viewer/preview/BookPreviewNullFragment.kt @@ -0,0 +1,5 @@ +package org.librarysimplified.viewer.preview + +import androidx.fragment.app.Fragment + +class BookPreviewNullFragment : Fragment() diff --git a/simplified-viewer-preview/src/main/res/layout/activity_book_preview.xml b/simplified-viewer-preview/src/main/res/layout/activity_book_preview.xml index 30bcf5675..728772e19 100644 --- a/simplified-viewer-preview/src/main/res/layout/activity_book_preview.xml +++ b/simplified-viewer-preview/src/main/res/layout/activity_book_preview.xml @@ -1,4 +1,5 @@ +