diff --git a/.github/claude/reviewer/review.mjs b/.github/claude/reviewer/review.mjs index c3160a187..b86a3a7f2 100644 --- a/.github/claude/reviewer/review.mjs +++ b/.github/claude/reviewer/review.mjs @@ -102,6 +102,36 @@ function extractJson(text) { return JSON.parse(candidate.slice(start, end + 1)); } +/// One-shot, tool-less model call that fixes the reviewer's own malformed JSON (typically an +/// unescaped quote or raw newline inside a finding's comment). Content is preserved; only +/// syntax is repaired. Costs one cheap turn instead of discarding a completed review pass. +async function repairJsonWithModel(brokenText) { + let repaired = ''; + const iterator = query({ + prompt: + 'The following text should be exactly one valid JSON object but fails to parse. ' + + 'Reply with ONLY the corrected, valid JSON object — no code fences, no commentary. ' + + 'Preserve every property and value; fix escaping/syntax only.\n\n' + brokenText, + options: { + model: MODEL, + allowedTools: [], + permissionMode: 'bypassPermissions', + maxTurns: 1, + }, + }); + for await (const msg of iterator) { + if (msg.type === 'assistant') { + const content = msg.message?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'text' && block.text) repaired = block.text; + } + } + } + } + return extractJson(repaired); +} + async function runAgent() { let finalText = ''; let turns = 0; @@ -210,7 +240,13 @@ async function main() { let parsed; try { if (!finalText) throw new Error('agent produced no text output'); - parsed = extractJson(finalText); + try { + parsed = extractJson(finalText); + } catch (parseError) { + // A finished review with broken JSON is worth one repair attempt before discarding. + console.warn(`Agent JSON malformed (${parseError.message}); attempting model repair…`); + parsed = await repairJsonWithModel(finalText); + } if (!parsed.verdict || !parsed.summary || !Array.isArray(parsed.findings)) { throw new Error('JSON missing verdict/summary/findings'); } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 825f42a8f..61709917c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,14 +9,14 @@ on: jobs: build: # https://github.com/actions/runner-images/tree/main/images/macos - runs-on: macos-26 + runs-on: xcode-27 timeout-minutes: 30 steps: - uses: actions/checkout@v4 - - name: Select Xcode 26.0 + - name: Select Xcode 27.0 run: | - ls -1 /Applications | grep -E '^Xcode_26\.' || true - sudo xcode-select -s "/Applications/Xcode_26.4.app" + ls -1 /Applications | grep -E '^Xcode_27' || true + sudo xcode-select -s "/Applications/Xcode_27.0.app" xcodebuild -version xcrun simctl list runtimes @@ -29,7 +29,7 @@ jobs: - name: Reset Simulator run: | set -x - export DEVELOPER_DIR="/Applications/Xcode_26.4.app" + export DEVELOPER_DIR="/Applications/Xcode_27.0.app" xcrun simctl shutdown all || true xcrun simctl erase all || true launchctl kickstart -k gui/$UID/com.apple.CoreSimulator.CoreSimulatorService || true diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index aa14b0c88..e51dfa96e 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -59,6 +59,6 @@ jobs: BASE_REF: ${{ github.event.pull_request.base.ref }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} REVIEW_MODEL: claude-opus-4-8 - REVIEW_MAX_TURNS: '50' # agent only reads/reports (posting is done by the script), so turns go far + REVIEW_MAX_TURNS: '100' # agent only reads/reports (posting is done by the script), so turns go far IS_SANDBOX: '1' run: node .github/claude/reviewer/review.mjs diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index 3a744ec3b..db7350c33 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -7,9 +7,9 @@ objects = { /* Begin PBXBuildFile section */ - 0199296D1E63EE13FC3558E6 /* WebAuthenticating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377C4C967D9AE1D471C2207C /* WebAuthenticating.swift */; }; - 0235B7EE8FA51C49AFBBBE71 /* IntegrationConnectionPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8030B619D18CA088DAA40A /* IntegrationConnectionPayload.swift */; }; 02C668174D24A70B4CD6FCB1 /* IntegrationConnectionStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269BB56618B150DCAA2CA5FB /* IntegrationConnectionStoreTests.swift */; }; + 048E8DAA13C4530F4419A030 /* MediaServerProvider+Icon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AC45C580EED027394A645B /* MediaServerProvider+Icon.swift */; }; + 058C840BB5E944B2899BC769 /* ReviewPromptServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F83542FF9A240EC87E55AC8 /* ReviewPromptServiceTests.swift */; }; 07416E5AD384927D90BFB6EE /* PasskeyCreatingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C084D4BC05BF6B413C86C6F0 /* PasskeyCreatingView.swift */; }; 0CEC6B48350FC9C0A0B7D1C0 /* SyncTasksStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 700DFD61111AC10253D6EBAA /* SyncTasksStorageTests.swift */; }; 0EF52614EF6770D09CA81CE4 /* IntegrationConnectedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C564A7BDE3A2D98E02BF8B /* IntegrationConnectedView.swift */; }; @@ -17,10 +17,12 @@ 17239057BBE31E405AFFBBCD /* IntegrationServerFoundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3511F737DCDEE7A12B713AE /* IntegrationServerFoundView.swift */; }; 18D0AD99D1AAA10976F75C11 /* BPNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55F466D61AE62A69DE4D371 /* BPNavigation.swift */; }; 1A26F7D72FFD6D625A313FC5 /* AudioMetadataServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC40E520F644AFA46DAD76B0 /* AudioMetadataServiceTests.swift */; }; + 2260D77EDD8BEEB0D433CD18 /* IntegrationHTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5265B16421BFC176BFFE4EA5 /* IntegrationHTTPClient.swift */; }; 232F1DA50BB33EB5A629B8B7 /* PlayableChapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 689E2BCD29DC1D9253949A53 /* PlayableChapterTests.swift */; }; 2B283D695990DA665D1661CC /* mp3_NO_chapters.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 12EDB8401F38342155CBBC91 /* mp3_NO_chapters.mp3 */; }; 2D7D5973F598D517CBCCE0ED /* PreferencesSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4D25EF2A5FFF30CF12F2C21 /* PreferencesSyncService.swift */; }; 39313742614BCCA918DA7C0E /* IntegrationConnectionFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DA0C0229963991F8CE1E941 /* IntegrationConnectionFlowView.swift */; }; + 3F058A74415A3AF6C17DA8F4 /* IntegrationHostResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE48B26D3726D5519F45ED9 /* IntegrationHostResolverTests.swift */; }; 3F66408A2E162ABF00356522 /* AudioMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6640892E162ABF00356522 /* AudioMetadataService.swift */; }; 3F66408B2E162ABF00356522 /* AudioMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6640892E162ABF00356522 /* AudioMetadataService.swift */; }; 3F66408D2E172DF500356522 /* MappingModel_v9_to_v10.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 3F66408C2E172DF500356522 /* MappingModel_v9_to_v10.xcmappingmodel */; }; @@ -36,11 +38,11 @@ 3F7B643D2E0FA0B800299D97 /* GraphQLClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7B643C2E0FA0B800299D97 /* GraphQLClient.swift */; }; 3F7B64422E0FB27000299D97 /* AnyCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7B64412E0FB27000299D97 /* AnyCodable.swift */; }; 3F7B64452E0FB29600299D97 /* BooksData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7B64442E0FB29600299D97 /* BooksData.swift */; }; + A179BB26325B4699A9EA15CA /* BookByIdData.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDE1A8E1D323493B91FA8E1C /* BookByIdData.swift */; }; 3F7B64472E0FB2A900299D97 /* AudiobookEditionsData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7B64462E0FB2A900299D97 /* AudiobookEditionsData.swift */; }; 3F7B64492E0FB2C100299D97 /* InsertUserBookData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F7B64482E0FB2C100299D97 /* InsertUserBookData.swift */; }; 3FA43BF82E110B4900DF9B1F /* SimpleHardcoverBook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA43BF72E110B4400DF9B1F /* SimpleHardcoverBook.swift */; }; 3FA43BF92E110B4900DF9B1F /* SimpleHardcoverBook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA43BF72E110B4400DF9B1F /* SimpleHardcoverBook.swift */; }; - 3FDACF42BAD03DBFA7AD918B /* IntegrationHTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5265B16421BFC176BFFE4EA5 /* IntegrationHTTPClient.swift */; }; 3FF5BA662E10EA6F00B82BCC /* HardcoverBook+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF5BA652E10EA5F00B82BCC /* HardcoverBook+CoreDataProperties.swift */; }; 3FF5BA672E10EA6F00B82BCC /* HardcoverBook+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF5BA652E10EA5F00B82BCC /* HardcoverBook+CoreDataProperties.swift */; }; 3FF5BA692E10EA8000B82BCC /* HardcoverBook+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF5BA682E10EA8000B82BCC /* HardcoverBook+CoreDataClass.swift */; }; @@ -130,6 +132,10 @@ 4160A09223F2DE130039166B /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 4160A09423F2DE130039166B /* Localizable.stringsdict */; }; 4160A0A123F304530039166B /* LocalizableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4160A0A023F304530039166B /* LocalizableLabel.swift */; }; 4163E313214AC43000072AA2 /* ImportOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4163E312214AC43000072AA2 /* ImportOperationTests.swift */; }; + 28EEB2588DE468A6B8C15272 /* RefreshTaskOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65962B5F03D221629A83C22C /* RefreshTaskOperationTests.swift */; }; + 3EBBB264D481A0F2DF48763E /* ListSyncRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 098A98893A8783E8BE1C5420 /* ListSyncRefreshServiceTests.swift */; }; + FDA22898B1CD320A1D8F6C52 /* QueuedTaskDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC06443EFD052E697036798 /* QueuedTaskDisplayTests.swift */; }; + 74054210C96493349539697D /* NetworkClientQueryEncodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5359C57600F2E373E8F33D39 /* NetworkClientQueryEncodingTests.swift */; }; 41640A3724416EE8004FB97B /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 41640A3624416EE8004FB97B /* Intents.intentdefinition */; settings = {ATTRIBUTES = (no_codegen, ); }; }; 41640A452441ACE2004FB97B /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 41640A3624416EE8004FB97B /* Intents.intentdefinition */; }; 4165EE0520A743D500616EDF /* BookPlayer.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 4165EDF920A743D500616EDF /* BookPlayer.xcdatamodeld */; }; @@ -186,6 +192,7 @@ 41A1B129226F88C500EA0400 /* Folder+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A1B11B226F88C500EA0400 /* Folder+CoreDataClass.swift */; }; 41A1B12A226F88C500EA0400 /* Folder+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A1B11C226F88C500EA0400 /* Folder+CoreDataProperties.swift */; }; 41A1B12D226FC7E500EA0400 /* ImportManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 416B63A72146354B007D04B1 /* ImportManager.swift */; }; + 6D464C6A588947878DCF1DB4 /* ImportConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E00DA9C0FECB434386459560 /* ImportConfirmationView.swift */; }; 41A1B12E226FC7E500EA0400 /* ImportOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41E34E4F2138EA8200A3997C /* ImportOperation.swift */; }; 41A1B12F226FE0F900EA0400 /* Notification+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 416B640C1E72F362000E955A /* Notification+BookPlayer.swift */; }; 41A1B131226FE33500EA0400 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D367F7671FA2A6F000FEDB37 /* Constants.swift */; }; @@ -280,33 +287,31 @@ 41F1A204254B09C00043FCF3 /* Themeable in Frameworks */ = {isa = PBXBuildFile; productRef = 41F1A203254B09C00043FCF3 /* Themeable */; }; 41F1A20D254B0A0C0043FCF3 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 41F1A20C254B0A0C0043FCF3 /* Sentry */; }; 41F1A228254B0C6C0043FCF3 /* ZipArchive in Frameworks */ = {isa = PBXBuildFile; productRef = 41F1A227254B0C6C0043FCF3 /* ZipArchive */; }; + 45FE3490229249F95A3CF5FF /* IntegrationHostResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3451CA062919E03CEB975E92 /* IntegrationHostResolver.swift */; }; + 4DCB4D54E03F9CDA56865A89 /* ExternalStreamResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51357B087F7FB3CF6E08296 /* ExternalStreamResolver.swift */; }; + F2083D16D6C22A37C49AC2B7 /* ExternalProgressProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ADE40DF29059A5B97E37D6 /* ExternalProgressProviding.swift */; }; + ED7E919E39E0454432171664 /* ExternalProgressService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C85605CDB6C6075AADBE2D15 /* ExternalProgressService.swift */; }; 4645F9FD2D1E46AC00A04257 /* SwipeInlineTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4645F9FC2D1E46AC00A04257 /* SwipeInlineTip.swift */; }; 465D87522D3195D600A4AA47 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465D87512D3195D600A4AA47 /* BookmarksView.swift */; }; 465D87542D31965100A4AA47 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465D87532D31965100A4AA47 /* BookmarksViewModel.swift */; }; 466E4B7066F10717C8CE7B3E /* LibraryServiceReloadChaptersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11C418B028982CF431C589F /* LibraryServiceReloadChaptersTests.swift */; }; 4689C06D2D270A7100D6C169 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 419B375423B8D5A500128A8F /* Localizable.strings */; }; 46EEDDC92D23154C0063811F /* VoiceOverService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69343D322133844D000C425E /* VoiceOverService.swift */; }; - 4E6F3810544774F70335675E /* PKCE.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E85F55E03D1981F097661C /* PKCE.swift */; }; - 5126F121258E9F18009965DC /* URL+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5126F120258E9F18009965DC /* URL+BookPlayer.swift */; }; 5126F122258E9F18009965DC /* URL+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5126F120258E9F18009965DC /* URL+BookPlayer.swift */; }; 5126F123258E9F18009965DC /* URL+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5126F120258E9F18009965DC /* URL+BookPlayer.swift */; }; 5163037679ED3EE0D1866A8F /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FC8B923A5506F865253B97C /* AppServices.swift */; }; 52AA441379FD94339FEECB55 /* SortPreferencesResolving.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6BC9E7E76B9BAB7A0D88D2 /* SortPreferencesResolving.swift */; }; 59384C1A0B33B9EB42AE4C75 /* ChaptersViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F496A2BF4CBFFC745C453D42 /* ChaptersViewModelTests.swift */; }; + 5A589702CFB4A8F8EC55AC3F /* SortIntegrationHarness.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF448F75392B02FC5451289 /* SortIntegrationHarness.swift */; }; 5A9506FC8DAB858289E0EF89 /* IntegrationServerAddressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 623E13D0C96857091397B5EF /* IntegrationServerAddressTests.swift */; }; + 5B1559A45658B195521CC454 /* SyncResponseFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C6581B92E4D292C2AFC361A /* SyncResponseFixtures.swift */; }; 5E20E8B93A65A58C51F1FF52 /* PKCETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A989BA90E16F60920A1DB0 /* PKCETests.swift */; }; + 5E699DCEB4EB06CB0A18A770 /* IntegrationConnectionPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8030B619D18CA088DAA40A /* IntegrationConnectionPayload.swift */; }; 62793611272CBE910097837D /* ImportFileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4163E3102148606600072AA2 /* ImportFileItem.swift */; }; 62AAE22D274AA6DE001EB9FF /* LibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62AAE22B274AA3EB001EB9FF /* LibraryService.swift */; }; 62AAE22E274AA6DE001EB9FF /* LibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62AAE22B274AA3EB001EB9FF /* LibraryService.swift */; }; 62AAE231274ABB5D001EB9FF /* LibraryServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62AAE230274ABB5D001EB9FF /* LibraryServiceTests.swift */; }; - 689DBBD518EB5F7760ED1518 /* QueryTimeSortRefactorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C78CCAAFEB5A9D78AA108EC /* QueryTimeSortRefactorTests.swift */; }; - 5B1559A45658B195521CC454 /* SyncResponseFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C6581B92E4D292C2AFC361A /* SyncResponseFixtures.swift */; }; - 5A589702CFB4A8F8EC55AC3F /* SortIntegrationHarness.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF448F75392B02FC5451289 /* SortIntegrationHarness.swift */; }; - 9DA710A0CC53EEC5F7AF93D3 /* QueryTimeSortSpikeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53AE3D4C955A493470C8EAA9 /* QueryTimeSortSpikeTests.swift */; }; - 759B9B1691932EB7A815CFFF /* SortCharacterizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F448E8BBF01860FC65EB697A /* SortCharacterizationTests.swift */; }; 62CADBA52725BCB200A4A98F /* AVAudioAssetImageDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 624AB20A2724808600F6A486 /* AVAudioAssetImageDataProvider.swift */; }; - 62CADBA82725BCE800A4A98F /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 62CADBA72725BCE800A4A98F /* Kingfisher */; }; - 62CADBAA2725BCF000A4A98F /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 62CADBA92725BCF000A4A98F /* Kingfisher */; }; 62CADBAB2725C23A00A4A98F /* ArtworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 415460F525E57B4F0085F8D9 /* ArtworkService.swift */; }; 62CADBAE2725FE2900A4A98F /* AVAudioAssetImageDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 624AB20A2724808600F6A486 /* AVAudioAssetImageDataProvider.swift */; }; 62CADBAF2725FE2C00A4A98F /* ArtworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 415460F525E57B4F0085F8D9 /* ArtworkService.swift */; }; @@ -378,12 +383,6 @@ 631C75C92AB92C540013E7E5 /* BPPushPresentationFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631C75C82AB92C540013E7E5 /* BPPushPresentationFlow.swift */; }; 631C75CC2AB92FA60013E7E5 /* BPModalPresentationFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631C75CB2AB92FA60013E7E5 /* BPModalPresentationFlow.swift */; }; 632119D72E543973005B3E10 /* ItemListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632119D62E543973005B3E10 /* ItemListViewModel.swift */; }; - 632132502F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 6321324F2F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel */; }; - 632132512F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 6321324F2F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel */; }; - 632132522F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 6321324F2F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel */; }; - 632132532F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 6321324F2F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel */; }; - 632132542F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 6321324F2F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel */; }; - 632132552F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 6321324F2F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel */; }; 6327E0C62ADB9913004780DC /* DownloadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB20EA429A281140021663B /* DownloadState.swift */; }; 6327E0C72ADB9914004780DC /* DownloadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB20EA429A281140021663B /* DownloadState.swift */; }; 632941402AEEE739000AD2EE /* CircularView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6329413F2AEEE739000AD2EE /* CircularView.swift */; }; @@ -466,6 +465,12 @@ 6357F11A2A8BA084007947FC /* BPURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6357F1182A8BA084007947FC /* BPURLSession.swift */; }; 636086012C5B3EB400341D78 /* CustomRewindIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 636086002C5B3EB400341D78 /* CustomRewindIntent.swift */; }; 63652C732E6E8A9700231202 /* ChaptersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63652C722E6E8A9700231202 /* ChaptersView.swift */; }; + 63673F96304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 63673F95304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel */; }; + 63673F97304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 63673F95304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel */; }; + 63673F98304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 63673F95304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel */; }; + 63673F99304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 63673F95304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel */; }; + 63673F9A304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 63673F95304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel */; }; + 63673F9B304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 63673F95304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel */; }; 63682CBE2E2F422900C15FC1 /* DebugFileTransferable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63682CBD2E2F422900C15FC1 /* DebugFileTransferable.swift */; }; 636E77802DF33DA200D4DC0A /* BP+ErrorAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63CD85262CE3064600EDBEA8 /* BP+ErrorAlerts.swift */; }; 636E77892DF4C76600D4DC0A /* JellyfinRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 636E77882DF4C76600D4DC0A /* JellyfinRootView.swift */; }; @@ -499,13 +504,7 @@ 638352CE2E67F327009EE79E /* TasksDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638352C72E67F327009EE79E /* TasksDataManager.swift */; }; 638352D02E67F327009EE79E /* SyncTasksModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638352C52E67F327009EE79E /* SyncTasksModels.swift */; }; 638352D12E67F327009EE79E /* SwiftDataModels+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638352C82E67F327009EE79E /* SwiftDataModels+Extensions.swift */; }; - 638487A22EC7722400DF442B /* AudiobookShelfLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6384879F2EC7722400DF442B /* AudiobookShelfLibrary.swift */; }; - 638487A32EC7722400DF442B /* AudiobookShelfLibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6384879A2EC7722400DF442B /* AudiobookShelfLibraryItem.swift */; }; - 638487A42EC7722400DF442B /* AudiobookShelfConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6384879D2EC7722400DF442B /* AudiobookShelfConnectionService.swift */; }; - 638487A52EC7722400DF442B /* AudiobookShelfConnectionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6384879C2EC7722400DF442B /* AudiobookShelfConnectionData.swift */; }; - 638487A72EC7722400DF442B /* AudiobookShelfAudiobookDetailsData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487962EC7722400DF442B /* AudiobookShelfAudiobookDetailsData.swift */; }; 638487B32EC7750100DF442B /* AudiobookShelfConnectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487AB2EC7750100DF442B /* AudiobookShelfConnectionViewModel.swift */; }; - 638487B72EC7752000DF442B /* AudiobookShelfLibraryLevelData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487B62EC7752000DF442B /* AudiobookShelfLibraryLevelData.swift */; }; 638487BB2EC7764800DF442B /* AudiobookShelfLibraryListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487B82EC7764800DF442B /* AudiobookShelfLibraryListItemView.swift */; }; 638487BF2EC7765000DF442B /* AudiobookShelfLibraryGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487BC2EC7765000DF442B /* AudiobookShelfLibraryGridItemView.swift */; }; 638487C32EC7767000DF442B /* AudiobookShelfLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487C22EC7767000DF442B /* AudiobookShelfLibraryViewModel.swift */; }; @@ -516,8 +515,6 @@ 638487CE2EC77B1200DF442B /* AudiobookShelfAudiobookDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487CB2EC77B1200DF442B /* AudiobookShelfAudiobookDetailsViewModel.swift */; }; 638761D32E62BD7A009332E8 /* ItemListSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638761D22E62BD7A009332E8 /* ItemListSelectionView.swift */; }; 638D91A82E60AAA600B62BDD /* ImportOperationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638D91A72E60AAA600B62BDD /* ImportOperationState.swift */; }; - 638E64CE2B8E1CFD00DCFA3B /* SyncTasksCountService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638E64CD2B8E1CFD00DCFA3B /* SyncTasksCountService.swift */; }; - 638E64CF2B8E1CFD00DCFA3B /* SyncTasksCountService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638E64CD2B8E1CFD00DCFA3B /* SyncTasksCountService.swift */; }; 6397206F2CA71D600045A4DB /* .swift-format in Resources */ = {isa = PBXBuildFile; fileRef = 6397206E2CA71D600045A4DB /* .swift-format */; }; 639720722CAAF8290045A4DB /* LastPlayedProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 639720712CAAF8290045A4DB /* LastPlayedProvider.swift */; }; 639720832CAB0C380045A4DB /* LastPlayedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 639720822CAB0C380045A4DB /* LastPlayedView.swift */; }; @@ -566,6 +563,13 @@ 63B4A3C42E93749300784A22 /* DynamicAccessibilityLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63B4A3C32E93749300784A22 /* DynamicAccessibilityLabel.swift */; }; 63B4A3C62E93858C00784A22 /* PlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63B4A3C52E93858C00784A22 /* PlayerControlsView.swift */; }; 63B50F052B692E4200BCABBA /* ListSyncRefreshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63B50F042B692E4200BCABBA /* ListSyncRefreshService.swift */; }; + F015116186D7F12201CDB4C0 /* SyncPauseReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 185FB83C0CF083AC102D8753 /* SyncPauseReporter.swift */; }; + 4907B9D7B059A2A93D35AAE0 /* UploadContinuationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DAECA780F8170162DAD1492 /* UploadContinuationController.swift */; }; + AD7114A34343C516BD1890E2 /* BackgroundSessionWakeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EEA5B438944574494C5385F /* BackgroundSessionWakeCoordinator.swift */; }; + D071ABD44684949C58C7D8D0 /* PromptSurfaceArbiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B6A047D0689D9E2E2D9749 /* PromptSurfaceArbiter.swift */; }; + 9AB6B44F84D195FCCF7A4F9F /* PromptSurfaceArbiterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4833D717C7B97F7B5DFDC5 /* PromptSurfaceArbiterTests.swift */; }; + C0C40794A90EEED394B4C957 /* PlaybackAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3B31C1E62A380C33C60FF9B /* PlaybackAlerts.swift */; }; + 5823F9DE917AE83D3C4DC04E /* PlaybackFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05D3B1199FC741AB2FDED67 /* PlaybackFailure.swift */; }; 63B760E82C31FFC600AA98C7 /* SupportFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63B760E72C31FFC600AA98C7 /* SupportFlowCoordinator.swift */; }; 63B760F72C32734000AA98C7 /* SecondOnboardingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63B760F62C32734000AA98C7 /* SecondOnboardingResponse.swift */; }; 63B760F92C32738E00AA98C7 /* StoryAccountSubscriptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63B760F82C32738E00AA98C7 /* StoryAccountSubscriptionService.swift */; }; @@ -599,8 +603,6 @@ 63C48D522E50AAA1005FBB96 /* AccountPasskeySectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C48D532E50AAA1005FBB96 /* AccountPasskeySectionView.swift */; }; 63C6C2E62B5029BC00FFE0D8 /* SettingsAutolockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C6C2E52B5029BC00FFE0D8 /* SettingsAutolockView.swift */; }; 63C6C2E82B5029FE00FFE0D8 /* SettingsAutolockViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C6C2E72B5029FE00FFE0D8 /* SettingsAutolockViewModel.swift */; }; - 63C6C30D2B538D8500FFE0D8 /* SyncTasksStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C6C30B2B538B7A00FFE0D8 /* SyncTasksStorage.swift */; }; - 63C6C30E2B538D8600FFE0D8 /* SyncTasksStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C6C30B2B538B7A00FFE0D8 /* SyncTasksStorage.swift */; }; 63C6C3112B54F16800FFE0D8 /* LibraryItemSyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C6C30F2B54F14800FFE0D8 /* LibraryItemSyncOperation.swift */; }; 63C6C3122B54F16800FFE0D8 /* LibraryItemSyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C6C30F2B54F14800FFE0D8 /* LibraryItemSyncOperation.swift */; }; 63C6C3192B5E102200FFE0D8 /* SyncTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C6C3172B5E0FE700FFE0D8 /* SyncTask.swift */; }; @@ -621,6 +623,7 @@ 63E15DA42D932695005B42E5 /* SleepTimerCommandsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E15DA32D932695005B42E5 /* SleepTimerCommandsView.swift */; }; 63E1FF8D2E6C835400F2E003 /* ItemDetailsFooterSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E1FF8C2E6C835400F2E003 /* ItemDetailsFooterSectionView.swift */; }; 63E1FF8F2E6C848200F2E003 /* ItemDetailsTitleSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E1FF8E2E6C848200F2E003 /* ItemDetailsTitleSectionView.swift */; }; + 234660AA5CF5435784D5AB09 /* ItemDetailsExternalResourceSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F45535E22CB7445EB5DCAA5F /* ItemDetailsExternalResourceSectionView.swift */; }; 63E1FF912E6C8C2000F2E003 /* SyncJobType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E1FF902E6C8C2000F2E003 /* SyncJobType.swift */; }; 63E1FF922E6C8C2000F2E003 /* SyncJobType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E1FF902E6C8C2000F2E003 /* SyncJobType.swift */; }; 63E54C322D494E110040355D /* RemoteItemListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E54C312D494E110040355D /* RemoteItemListViewModel.swift */; }; @@ -646,22 +649,22 @@ 63FB9E032E5774A500D1966F /* ListStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63FB9E022E5774A500D1966F /* ListStateManager.swift */; }; 63FCBBBE2DF7404000C50035 /* JellyfinLibraryListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63FCBBBD2DF7404000C50035 /* JellyfinLibraryListItemView.swift */; }; 66DF1F3E6AFECB623A558F04 /* IntegrationDisconnectedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FDA0B60D7BE4CFE100A612 /* IntegrationDisconnectedView.swift */; }; + 689DBBD518EB5F7760ED1518 /* QueryTimeSortRefactorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C78CCAAFEB5A9D78AA108EC /* QueryTimeSortRefactorTests.swift */; }; 6906A55021720FDF00A9E0B2 /* BookSortServiceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6906A54F21720FDF00A9E0B2 /* BookSortServiceTest.swift */; }; 6906A553217211C600A9E0B2 /* StubFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6906A552217211C600A9E0B2 /* StubFactory.swift */; }; 69343D332133844D000C425E /* VoiceOverService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69343D322133844D000C425E /* VoiceOverService.swift */; }; - FBE2B360883B414695A54628 /* ReviewPromptService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C80A51B636D4C7381529505 /* ReviewPromptService.swift */; }; - 058C840BB5E944B2899BC769 /* ReviewPromptServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F83542FF9A240EC87E55AC8 /* ReviewPromptServiceTests.swift */; }; 69343D36213A07B4000C425E /* VoiceOverServiceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69343D35213A07B4000C425E /* VoiceOverServiceTest.swift */; }; 6C25A0E1ADBF2325CA78E8A4 /* mp3_NO_toc_v23.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 1BA32C89144C68DD64FD0B62 /* mp3_NO_toc_v23.mp3 */; }; 6DF751D96098B1A3224FDF43 /* IntegrationServerAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFF7B7B6E62B274D230C207 /* IntegrationServerAddress.swift */; }; 6EC082E4BEF0CC2FAA42C0A7 /* mp3_WITH_toc.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 8D3EFD93E6D1E0366A3FD1E9 /* mp3_WITH_toc.mp3 */; }; - 760180C62F243705DE6B3224 /* IntegrationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D0FA9BA32EDF5F367C757B /* IntegrationError.swift */; }; + 759B9B1691932EB7A815CFFF /* SortCharacterizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F448E8BBF01860FC65EB697A /* SortCharacterizationTests.swift */; }; + 77980C5A9B7CF1A948FE7CF1 /* PKCE.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E85F55E03D1981F097661C /* PKCE.swift */; }; 78D8C50324731BBDF9E9281E /* LibraryOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBF775DDE66D0AA08818D45 /* LibraryOptionsView.swift */; }; 797E853F1A6F9CE2A713FD8B /* m4b_WELLFORMED.m4b in Resources */ = {isa = PBXBuildFile; fileRef = D6F6A0DE6FBE7828D4C7EC9C /* m4b_WELLFORMED.m4b */; }; 7CAD4B67352939D0A1E54A21 /* IntegrationConnectionFormViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3724BB21B1B88177C60BE4D8 /* IntegrationConnectionFormViewModelProtocol.swift */; }; - 81173188169F2573B4F5AA90 /* AudiobookShelfOIDCFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75010F6AB433BBEEB7FA9376 /* AudiobookShelfOIDCFlow.swift */; }; + 82BB1A19497560FC62C4741C /* WebAuthenticating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377C4C967D9AE1D471C2207C /* WebAuthenticating.swift */; }; 8322A3C6479B099448C63C47 /* IntegrationLibraryViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E070421575334E1CD85C049 /* IntegrationLibraryViewModelProtocol.swift */; }; - 843575DDF1CEEBC9A2EF015C /* IntegrationConnectionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587EDC7DEA9D2985635F1161 /* IntegrationConnectionStore.swift */; }; + 89058F6081ECE1258DE9607D /* AudiobookShelfOIDCFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75010F6AB433BBEEB7FA9376 /* AudiobookShelfOIDCFlow.swift */; }; 8A0883CB5B4914B2DCB70A5E /* AudiobookShelfOIDCFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC10E1E47E1F6982D8F6A552 /* AudiobookShelfOIDCFlowTests.swift */; }; 8A2A22392CEFEB8E00E73A2D /* AdaptiveVGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A2A22382CEFEB8800E73A2D /* AdaptiveVGrid.swift */; }; 8A495CC62CC85D27001B4244 /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 8A495CC52CC85D27001B4244 /* JellyfinAPI */; }; @@ -674,18 +677,59 @@ 8A9D0D302CD0E074007A924D /* SingleFileDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9D0D2F2CD0E06F007A924D /* SingleFileDownloadService.swift */; }; 8AAA4F4C2CF1FF8C009D0777 /* MockCoordinatorPresentationFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAA4F4B2CF1FF77009D0777 /* MockCoordinatorPresentationFlow.swift */; }; 8AAD8A4F2CEE3650000A4B4B /* View+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD8A4E2CEE3650000A4B4B /* View+BookPlayer.swift */; }; - 8AAD8A552CEE884F000A4B4B /* JellyfinConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD8A542CEE8849000A4B4B /* JellyfinConnectionService.swift */; }; - 8AAD8A572CEE88E5000A4B4B /* JellyfinConnectionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD8A562CEE88DE000A4B4B /* JellyfinConnectionData.swift */; }; + 8AC2579BAAAAFE0091DA9285 /* PKCE.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E85F55E03D1981F097661C /* PKCE.swift */; }; 8ADD46222CF29A02002E9C50 /* TextFieldFocused.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ADD46212CF29A02002E9C50 /* TextFieldFocused.swift */; }; - 8ADD46242CF38F9F002E9C50 /* JellyfinAudiobookDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ADD46232CF38F91002E9C50 /* JellyfinAudiobookDetailsView.swift */; }; 8ADD46262CF67592002E9C50 /* JellyfinAudiobookDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ADD46252CF67587002E9C50 /* JellyfinAudiobookDetailsViewModel.swift */; }; 8AE4AAC52CCBA16F00BAA927 /* JellyfinConnectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE4AAC42CCBA16F00BAA927 /* JellyfinConnectionViewModel.swift */; }; - 8AF18A332CF0A26200238F8D /* JellyfinLibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF18A322CF0A26200238F8D /* JellyfinLibraryItem.swift */; }; 8AF18A3B2CF0E92F00238F8D /* KeychainServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF18A3A2CF0E92800238F8D /* KeychainServiceMock.swift */; }; 9236C57F833C70652BDD1FA3 /* TabEditingEnvironmentKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DDD933F7FDEDC1BF67EC567 /* TabEditingEnvironmentKey.swift */; }; 9305492473601C69B999F588 /* VideoArtworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD948B0DFDC349DADE2A3D8 /* VideoArtworkView.swift */; }; 9586CECD8FB418C6CFB0A7DD /* IntegrationLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C407B5BD19D3CF9CC64EC7 /* IntegrationLibraryView.swift */; }; 98BA9BA4D6A94BC8BCCE5F8B /* IntegrationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D9D68F735D2519CDBC18495 /* IntegrationSettingsView.swift */; }; + 990442102F889201007E2A44 /* MediaServersLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9904420F2F8891F9007E2A44 /* MediaServersLoginView.swift */; }; + 990775772FF33BD700E35FAE /* Get in Frameworks */ = {isa = PBXBuildFile; productRef = 990775762FF33BD700E35FAE /* Get */; }; + 990775792FF33BE100E35FAE /* Get in Frameworks */ = {isa = PBXBuildFile; productRef = 990775782FF33BE100E35FAE /* Get */; }; + 9907757C2FF33C0C00E35FAE /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 9907757B2FF33C0C00E35FAE /* Kingfisher */; }; + 9907757E2FF33C1500E35FAE /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 9907757D2FF33C1500E35FAE /* Kingfisher */; }; + 990775802FF33D5300E35FAE /* Get in Frameworks */ = {isa = PBXBuildFile; productRef = 9907757F2FF33D5300E35FAE /* Get */; }; + 9911FBA92F71BD3000DC9A2B /* JellyfinConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD8A542CEE8849000A4B4B /* JellyfinConnectionService.swift */; }; + 9911FBAC2F71CD6300DC9A2B /* AsyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBAB2F71CD5F00DC9A2B /* AsyncOperation.swift */; }; + 9911FBAD2F71CD6300DC9A2B /* AsyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBAB2F71CD5F00DC9A2B /* AsyncOperation.swift */; }; + 9911FBAF2F71D03800DC9A2B /* SyncQueueService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBAE2F71D03100DC9A2B /* SyncQueueService.swift */; }; + 9911FBB02F71D03800DC9A2B /* SyncQueueService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBAE2F71D03100DC9A2B /* SyncQueueService.swift */; }; + 9911FBB22F71E54C00DC9A2B /* SyncQueueRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBB12F71E54600DC9A2B /* SyncQueueRepository.swift */; }; + 9911FBB32F71E54C00DC9A2B /* SyncQueueRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBB12F71E54600DC9A2B /* SyncQueueRepository.swift */; }; + 9911FBB52F72486D00DC9A2B /* ExternalUpdateProgressOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBB42F72486400DC9A2B /* ExternalUpdateProgressOperation.swift */; }; + 9911FBB62F72486D00DC9A2B /* ExternalUpdateProgressOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBB42F72486400DC9A2B /* ExternalUpdateProgressOperation.swift */; }; + 9911FBB82F72F46900DC9A2B /* QueuedSyncTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBB72F72F46200DC9A2B /* QueuedSyncTask.swift */; }; + A6E159ACC6815095CB182A05 /* TaskPause.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099268746F86BD2193897F88 /* TaskPause.swift */; }; + 9911FBB92F72F46900DC9A2B /* QueuedSyncTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBB72F72F46200DC9A2B /* QueuedSyncTask.swift */; }; + 693CFE4C25CFFA22F7151780 /* TaskPause.swift in Sources */ = {isa = PBXBuildFile; fileRef = 099268746F86BD2193897F88 /* TaskPause.swift */; }; + 9911FBBE2F74527800DC9A2B /* SyncQueueProgressMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBBD2F74526F00DC9A2B /* SyncQueueProgressMonitor.swift */; }; + F99B9D63217ABDAFED95696A /* QueueCounts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C877CDD6114F67F0CD2AFC /* QueueCounts.swift */; }; + 9911FBBF2F74527800DC9A2B /* SyncQueueProgressMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBBD2F74526F00DC9A2B /* SyncQueueProgressMonitor.swift */; }; + 7AA06DC64EDBA7F10753CF4C /* QueueCounts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C877CDD6114F67F0CD2AFC /* QueueCounts.swift */; }; + 9911FBC42F76122400DC9A2B /* FileUploadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBC32F76121C00DC9A2B /* FileUploadOperation.swift */; }; + 21B642BBCC9CEC43E00412CD /* PartUploadTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF4215FDCB8C2EA0F4A975D /* PartUploadTransport.swift */; }; + 9911FBC52F76122400DC9A2B /* FileUploadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBC32F76121C00DC9A2B /* FileUploadOperation.swift */; }; + CC6BB739AD7C3CAE40BB940C /* PartUploadTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF4215FDCB8C2EA0F4A975D /* PartUploadTransport.swift */; }; + 9911FBC72F7738CC00DC9A2B /* SyncableExternalResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBC62F7738C100DC9A2B /* SyncableExternalResource.swift */; }; + 9911FBC82F7738CC00DC9A2B /* SyncableExternalResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBC62F7738C100DC9A2B /* SyncableExternalResource.swift */; }; + 9911FBD72F7CACEC00DC9A2B /* QueuedTasksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9911FBD62F7CACE200DC9A2B /* QueuedTasksView.swift */; }; + 32F59104BFC8DDAA7DBEC934 /* QueuedTaskDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F26B24460975E0B0B15ABDA /* QueuedTaskDisplay.swift */; }; + 02C5C304687E934E42ABF54C /* SyncPauseReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC4219B7DA1CD232AE12888 /* SyncPauseReport.swift */; }; + 992D6E132FA98B4D0091849F /* AudiobookShelfConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6384879D2EC7722400DF442B /* AudiobookShelfConnectionService.swift */; }; + 992D6E142FA98B4D0091849F /* AudiobookShelfConnectionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6384879C2EC7722400DF442B /* AudiobookShelfConnectionData.swift */; }; + 992D6E152FA98B4D0091849F /* AudiobookShelfConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6384879D2EC7722400DF442B /* AudiobookShelfConnectionService.swift */; }; + 992D6E162FA98B4D0091849F /* AudiobookShelfConnectionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6384879C2EC7722400DF442B /* AudiobookShelfConnectionData.swift */; }; + 992D6E172FA98C5C0091849F /* AudiobookShelfLibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6384879A2EC7722400DF442B /* AudiobookShelfLibraryItem.swift */; }; + 992D6E182FA98C5C0091849F /* AudiobookShelfLibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6384879A2EC7722400DF442B /* AudiobookShelfLibraryItem.swift */; }; + 992D6E192FA98C8F0091849F /* AudiobookShelfLibraryLevelData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487B62EC7752000DF442B /* AudiobookShelfLibraryLevelData.swift */; }; + 992D6E1A2FA98C8F0091849F /* AudiobookShelfLibraryLevelData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487B62EC7752000DF442B /* AudiobookShelfLibraryLevelData.swift */; }; + 992D6E1B2FA98CBE0091849F /* AudiobookShelfLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6384879F2EC7722400DF442B /* AudiobookShelfLibrary.swift */; }; + 992D6E1C2FA98CBE0091849F /* AudiobookShelfLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6384879F2EC7722400DF442B /* AudiobookShelfLibrary.swift */; }; + 992D6E1D2FA98E1A0091849F /* AudiobookShelfAudiobookDetailsData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487962EC7722400DF442B /* AudiobookShelfAudiobookDetailsData.swift */; }; + 992D6E1E2FA98E1A0091849F /* AudiobookShelfAudiobookDetailsData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638487962EC7722400DF442B /* AudiobookShelfAudiobookDetailsData.swift */; }; 99329DB72F3AA8F6003F8E73 /* PlayControlsRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99329DB62F3AA8F6003F8E73 /* PlayControlsRowView.swift */; }; 99329DBB2F3AAA61003F8E73 /* ListeningProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99329DBA2F3AAA61003F8E73 /* ListeningProgressView.swift */; }; 99329DBD2F3AAAB4003F8E73 /* NavigationRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99329DBC2F3AAAB4003F8E73 /* NavigationRowView.swift */; }; @@ -703,8 +747,30 @@ 99329DD72F3E8A04003F8E73 /* SlickSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99329DD62F3E8A04003F8E73 /* SlickSlider.swift */; }; 99329DDB2F3ED5E5003F8E73 /* DurationPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99329DDA2F3ED5E5003F8E73 /* DurationPickerSheet.swift */; }; 9961DA72E9D53B8DA131BEB2 /* BPFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACFA02FA31A0CB0438C5228 /* BPFont.swift */; }; + 996D4CC52F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 996D4CC42F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel */; }; + 996D4CC62F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 996D4CC42F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel */; }; + 996D4CC72F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 996D4CC42F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel */; }; + 996D4CC82F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 996D4CC42F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel */; }; + 996D4CC92F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 996D4CC42F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel */; }; + 996D4CCA2F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 996D4CC42F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel */; }; + 996D4CD12F98A45300FC3B57 /* IntegrationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D0FA9BA32EDF5F367C757B /* IntegrationError.swift */; }; + 996D4CD22F98A45300FC3B57 /* IntegrationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D0FA9BA32EDF5F367C757B /* IntegrationError.swift */; }; + 996D4CD32F98A50C00FC3B57 /* IntegrationLibraryItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A823609A5B7EFC6D2D5D120 /* IntegrationLibraryItemProtocol.swift */; }; + 996D4CD42F98A50C00FC3B57 /* IntegrationLibraryItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A823609A5B7EFC6D2D5D120 /* IntegrationLibraryItemProtocol.swift */; }; 99B367FA2F4575C900C38F7F /* MediaAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99B367F92F4575C600C38F7F /* MediaAction.swift */; }; 99B367FC2F5108BF00C38F7F /* MediaActionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99B367FB2F5108BF00C38F7F /* MediaActionRow.swift */; }; + 99C919CF2F99A8C100F66081 /* IntegrationDetailsViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808E351A8B30ABCBA9EC00EF /* IntegrationDetailsViewModelProtocol.swift */; }; + 99C919D02F99A8C100F66081 /* IntegrationDetailsViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808E351A8B30ABCBA9EC00EF /* IntegrationDetailsViewModelProtocol.swift */; }; + 99D995872F719E7600BAF216 /* JellyfinConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD8A542CEE8849000A4B4B /* JellyfinConnectionService.swift */; }; + 99D995892F719E7E00BAF216 /* (null) in Sources */ = {isa = PBXBuildFile; }; + 99D9958A2F719E7E00BAF216 /* (null) in Sources */ = {isa = PBXBuildFile; }; + 99D9958F2F71AB6800BAF216 /* JellyfinLibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF18A322CF0A26200238F8D /* JellyfinLibraryItem.swift */; }; + 99D995902F71AB6800BAF216 /* JellyfinLibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF18A322CF0A26200238F8D /* JellyfinLibraryItem.swift */; }; + 99D995932F71ABAA00BAF216 /* JellyfinAudiobookDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ADD46232CF38F91002E9C50 /* JellyfinAudiobookDetailsView.swift */; }; + 99D995952F71ABBD00BAF216 /* JellyfinAudiobookDetailsData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99D995942F71ABB700BAF216 /* JellyfinAudiobookDetailsData.swift */; }; + 99D995962F71ABBD00BAF216 /* JellyfinAudiobookDetailsData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99D995942F71ABB700BAF216 /* JellyfinAudiobookDetailsData.swift */; }; + 99D995982F71B02D00BAF216 /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 99D995972F71B02D00BAF216 /* JellyfinAPI */; }; + 99D9959A2F71B05700BAF216 /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 99D995992F71B05700BAF216 /* JellyfinAPI */; }; 99F3C1EC2F5BCE1600AC91A2 /* LibraryItemRef.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C1EB2F5BCE1000AC91A2 /* LibraryItemRef.swift */; }; 99F3C1ED2F5BCE1600AC91A2 /* LibraryItemRef.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C1EB2F5BCE1000AC91A2 /* LibraryItemRef.swift */; }; 99F3C1F72F5F873900AC91A2 /* UUIDMigrationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C1F62F5F873500AC91A2 /* UUIDMigrationPolicy.swift */; }; @@ -712,7 +778,6 @@ 99F3C1F92F5F873900AC91A2 /* UUIDMigrationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C1F62F5F873500AC91A2 /* UUIDMigrationPolicy.swift */; }; 99F3C1FA2F5F873900AC91A2 /* UUIDMigrationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C1F62F5F873500AC91A2 /* UUIDMigrationPolicy.swift */; }; 99F3C1FB2F5F873900AC91A2 /* UUIDMigrationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C1F62F5F873500AC91A2 /* UUIDMigrationPolicy.swift */; }; - 99F3C1FC2F5F873900AC91A2 /* UUIDMigrationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C1F62F5F873500AC91A2 /* UUIDMigrationPolicy.swift */; }; 99F3C1FE2F5FC2F200AC91A2 /* SchemaV1SyncTasksModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C1FD2F5FC2E600AC91A2 /* SchemaV1SyncTasksModels.swift */; }; 99F3C1FF2F5FC2F200AC91A2 /* SchemaV1SyncTasksModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C1FD2F5FC2E600AC91A2 /* SchemaV1SyncTasksModels.swift */; }; 99F3C2042F5FC76D00AC91A2 /* MigrationPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C2032F5FC76700AC91A2 /* MigrationPlan.swift */; }; @@ -721,6 +786,22 @@ 99F3C2082F5FD41300AC91A2 /* SchemaV2SyncTasksModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C2062F5FD40C00AC91A2 /* SchemaV2SyncTasksModels.swift */; }; 99F3C20A2F62285800AC91A2 /* ApiResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C2092F62285500AC91A2 /* ApiResponse.swift */; }; 99F3C20B2F62285800AC91A2 /* ApiResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C2092F62285500AC91A2 /* ApiResponse.swift */; }; + 99F3C20E2F650F0900AC91A2 /* ExternalResource+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C20D2F650EEB00AC91A2 /* ExternalResource+CoreDataClass.swift */; }; + 99F3C20F2F650F0900AC91A2 /* ExternalResource+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C20D2F650EEB00AC91A2 /* ExternalResource+CoreDataClass.swift */; }; + 99F3C2112F650F1200AC91A2 /* ExternalResource+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C2102F650F0C00AC91A2 /* ExternalResource+CoreDataProperties.swift */; }; + 99F3C2122F650F1200AC91A2 /* ExternalResource+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C2102F650F0C00AC91A2 /* ExternalResource+CoreDataProperties.swift */; }; + 99F3C2142F65174700AC91A2 /* SimpleExternalResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C2132F65174000AC91A2 /* SimpleExternalResource.swift */; }; + 99F3C2152F65174700AC91A2 /* SimpleExternalResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C2132F65174000AC91A2 /* SimpleExternalResource.swift */; }; + 99F3C21F2F69D0AF00AC91A2 /* ExternalImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C21E2F69D0AC00AC91A2 /* ExternalImportView.swift */; }; + 99F3C2212F69D32E00AC91A2 /* ExternalImportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C2202F69D32800AC91A2 /* ExternalImportViewModel.swift */; }; + 1A304754D41FB1E66FDD5EAF /* ExternalImportEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1842BD89ED97A5DC8C0577 /* ExternalImportEvents.swift */; }; + 99F3C2342F6BBECC00AC91A2 /* JellyfinConnectionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD8A562CEE88DE000A4B4B /* JellyfinConnectionData.swift */; }; + 99F3C2352F6BBECC00AC91A2 /* JellyfinConnectionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAD8A562CEE88DE000A4B4B /* JellyfinConnectionData.swift */; }; + 99F3C2362F6D04AE00AC91A2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 418B6D041D2707F800F974FB /* Assets.xcassets */; }; + 99F3C2372F6D04AE00AC91A2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 418B6D041D2707F800F974FB /* Assets.xcassets */; }; + 99F3C2412F6E322800AC91A2 /* SchemaV3SyncTasksModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C2402F6E322000AC91A2 /* SchemaV3SyncTasksModels.swift */; }; + 99F3C2422F6E322800AC91A2 /* SchemaV3SyncTasksModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C2402F6E322000AC91A2 /* SchemaV3SyncTasksModels.swift */; }; + 9DA710A0CC53EEC5F7AF93D3 /* QueryTimeSortSpikeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53AE3D4C955A493470C8EAA9 /* QueryTimeSortSpikeTests.swift */; }; 9F00A5FA294F8BFE005EA316 /* ClearableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F00A5F9294F8BFE005EA316 /* ClearableTextField.swift */; }; 9F00A5FC294F9B9A005EA316 /* ItemDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F00A5FB294F9B9A005EA316 /* ItemDetailsView.swift */; }; 9F00A600295001C0005EA316 /* ItemDetailsArtworkSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F00A5FF295001C0005EA316 /* ItemDetailsArtworkSectionView.swift */; }; @@ -747,8 +828,8 @@ 9F2DC9DB2A008B28006CDF1F /* PricingRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2DC9DA2A008B28006CDF1F /* PricingRowView.swift */; }; 9F2DC9E52A014BB5006CDF1F /* PricingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2DC9E32A01313C006CDF1F /* PricingModel.swift */; }; 9F2DC9E62A014BB5006CDF1F /* PricingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2DC9E32A01313C006CDF1F /* PricingModel.swift */; }; - 9F2E00922A568F800001FE20 /* IdentifiersResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2E00912A568F800001FE20 /* IdentifiersResponse.swift */; }; - 9F2E00932A568F800001FE20 /* IdentifiersResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2E00912A568F800001FE20 /* IdentifiersResponse.swift */; }; + 9F2E00922A568F800001FE20 /* ItemsStatusResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2E00912A568F800001FE20 /* ItemsStatusResponse.swift */; }; + 9F2E00932A568F800001FE20 /* ItemsStatusResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2E00912A568F800001FE20 /* ItemsStatusResponse.swift */; }; 9F2E00DE2A5C2B810001FE20 /* StorageCloudDeletedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2E00DD2A5C2B810001FE20 /* StorageCloudDeletedViewModel.swift */; }; 9F3C436A284181690066D99A /* DataInitializerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3C435B28413C070066D99A /* DataInitializerCoordinator.swift */; }; 9F3C436B284181C70066D99A /* AlertPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3C4357284132640066D99A /* AlertPresenter.swift */; }; @@ -823,7 +904,9 @@ 9FBDBC7D287940D900D315A2 /* ContentsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBDBC7C287940D900D315A2 /* ContentsResponse.swift */; }; 9FBDBC7E287940D900D315A2 /* ContentsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBDBC7C287940D900D315A2 /* ContentsResponse.swift */; }; 9FBDBC802879C6C900D315A2 /* UploadItemResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBDBC7F2879C6C900D315A2 /* UploadItemResponse.swift */; }; + BEC7B87D99BB26DE10DEEBC7 /* MultipartUploadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1713BB63A647FA8447545EFD /* MultipartUploadResponse.swift */; }; 9FBDBC812879C6C900D315A2 /* UploadItemResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBDBC7F2879C6C900D315A2 /* UploadItemResponse.swift */; }; + 04376D0C9384C1655823B498 /* MultipartUploadResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1713BB63A647FA8447545EFD /* MultipartUploadResponse.swift */; }; 9FBF33EC29F6293C00501639 /* SimpleBookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBF33EB29F6293C00501639 /* SimpleBookmark.swift */; }; 9FBF33ED29F6293C00501639 /* SimpleBookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBF33EB29F6293C00501639 /* SimpleBookmark.swift */; }; 9FC1E44A2814C4DE00522FA8 /* NetworkClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FC1E4492814C4DE00522FA8 /* NetworkClient.swift */; }; @@ -856,7 +939,6 @@ 9FF383D32A40F97000BBAC11 /* MappingModel_v8_to_v9.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 9FF383D02A40F97000BBAC11 /* MappingModel_v8_to_v9.xcmappingmodel */; }; 9FF383D42A40F97000BBAC11 /* MappingModel_v8_to_v9.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 9FF383D02A40F97000BBAC11 /* MappingModel_v8_to_v9.xcmappingmodel */; }; 9FF383D52A40F97000BBAC11 /* MappingModel_v8_to_v9.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 9FF383D02A40F97000BBAC11 /* MappingModel_v8_to_v9.xcmappingmodel */; }; - 9FF710B72A212A19006490E0 /* QueuedSyncTasksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF710B62A212A19006490E0 /* QueuedSyncTasksView.swift */; }; 9FF710B92A213084006490E0 /* QueuedSyncTaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF710B82A213084006490E0 /* QueuedSyncTaskRowView.swift */; }; 9FF710BD2A215686006490E0 /* QueuedSyncTaskType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF710BC2A215686006490E0 /* QueuedSyncTaskType.swift */; }; 9FFCC08F289418CA00F4952E /* SimpleChapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FFCC08E289418CA00F4952E /* SimpleChapter.swift */; }; @@ -866,16 +948,21 @@ A7A0756EBA206652A801B9D3 /* VideoPlayerSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2107EC31217A7421B2BA6A /* VideoPlayerSurface.swift */; }; A7FA45CB2BD6567C9B5EA372 /* IntegrationTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643E169A5F153B754F726AE1 /* IntegrationTagsView.swift */; }; ADBA6DD206F76CF92C0765A3 /* WebAuthenticatingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4388D9707FA7C85F9D07E75 /* WebAuthenticatingTests.swift */; }; + AE284AB4D4139ED869288F99 /* IntegrationConnectionPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8030B619D18CA088DAA40A /* IntegrationConnectionPayload.swift */; }; B14881000000000000000001 /* IntegrationCustomHeadersSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B14881000000000000000002 /* IntegrationCustomHeadersSectionView.swift */; }; + B16A2A4FFFE86D46E799A773 /* IntegrationHostResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3451CA062919E03CEB975E92 /* IntegrationHostResolver.swift */; }; + 09E50F19D8C014240BD8A1BF /* ExternalStreamResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51357B087F7FB3CF6E08296 /* ExternalStreamResolver.swift */; }; + CF9C88BC3DDD1AE5833E2106 /* ExternalProgressProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ADE40DF29059A5B97E37D6 /* ExternalProgressProviding.swift */; }; + CDA937D56B8AFBA03402E979 /* ExternalProgressService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C85605CDB6C6075AADBE2D15 /* ExternalProgressService.swift */; }; B26B34D894351E8452FBB64B /* IntegrationServerInformationSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F919ABFCEF9B58EFE9D82E /* IntegrationServerInformationSectionView.swift */; }; B7F1E4A9D2C5B8F0A6E3C12A /* IntegrationImageSizing.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4D9C8B6E5F1D2A7C4E9B81B /* IntegrationImageSizing.swift */; }; + BF96AEBA456420ADE446427F /* URL+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5126F120258E9F18009965DC /* URL+BookPlayer.swift */; }; C318DDBC20A48D4700C3A17B /* BPMarqueeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C318DDBB20A48D4700C3A17B /* BPMarqueeLabel.swift */; }; C37A6873209F0F830063AEAC /* Credits.html in Resources */ = {isa = PBXBuildFile; fileRef = C37A6872209F0F830063AEAC /* Credits.html */; }; C39401E920DEE83200F3DC71 /* UIView+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C39401E820DEE83100F3DC71 /* UIView+BookPlayer.swift */; }; C3A479132094C8C300D92122 /* rubberBandDistance.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A479122094C8C300D92122 /* rubberBandDistance.swift */; }; C3A479152094CA3800D92122 /* UIImage+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A479142094CA3800D92122 /* UIImage+BookPlayer.swift */; }; C3A479192094CAF300D92122 /* UIViewController+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A479182094CAF300D92122 /* UIViewController+BookPlayer.swift */; }; - C3C998E7EA2919BB438B337C /* IntegrationLibraryItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A823609A5B7EFC6D2D5D120 /* IntegrationLibraryItemProtocol.swift */; }; C3EA7176218DEC870005D488 /* .swiftformat in Resources */ = {isa = PBXBuildFile; fileRef = C3EA7175218DEC870005D488 /* .swiftformat */; }; C3EC372E206EE0650094B4E8 /* SleepTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3EC372D206EE0650094B4E8 /* SleepTimer.swift */; }; C3FA301E20E0024900393DDA /* BPArtworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FA301D20E0024900393DDA /* BPArtworkView.swift */; }; @@ -885,7 +972,15 @@ C63BB893BC572482723FF7CE /* m4b_MALFORMED.m4b in Resources */ = {isa = PBXBuildFile; fileRef = A566F222C6660938B3A71638 /* m4b_MALFORMED.m4b */; }; C744B2254A1146AC9F78A68F /* PlayBookIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F847DAE94054533AACF8D85 /* PlayBookIntent.swift */; }; C86098383F7FC099EED24AF7 /* IntegrationQuickConnectViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 056B7A76F1A44CEC30562382 /* IntegrationQuickConnectViews.swift */; }; + C972F5D6BD6375832F59BF7E /* IntegrationHTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5265B16421BFC176BFFE4EA5 /* IntegrationHTTPClient.swift */; }; CA3B408256F8458669106CF9 /* IntegrationConnectionViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91D6855100BD1823B2674F /* IntegrationConnectionViewModelProtocol.swift */; }; + CB3844DD8C695EAD32A4A811 /* SyncQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF75FEB4DEC37DBE232CD140 /* SyncQueueTests.swift */; }; + 6C5741E68D44175ECF900294 /* UploadContinuationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04EDE7B20E949F916D8ECA2D /* UploadContinuationTests.swift */; }; + AD8D09AF57F6C7799943EA1F /* MissingItemsPassTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60EBB5E9BC0C94F511BD4044 /* MissingItemsPassTests.swift */; }; + 50EEEAE96509B6867FC9180B /* BackgroundSessionWakeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83C482DCC8DC2DF811A3C7BC /* BackgroundSessionWakeTests.swift */; }; + B2B1B1BF822F47AA40EE1B71 /* MultipartUploadEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F08C368941D45696C7891 /* MultipartUploadEngineTests.swift */; }; + 3F42C29BC62913D44C22CBC0 /* SyncPauseReportingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C64E54AA0319D8F687DFB6E4 /* SyncPauseReportingTests.swift */; }; + 786041B335FF61901E55666C /* TasksDataManagerStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2339A98D9659EA78CB738CF /* TasksDataManagerStoreTests.swift */; }; CCC7E1CC127610C3687B8A50 /* SortPreferencesResolving.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6BC9E7E76B9BAB7A0D88D2 /* SortPreferencesResolving.swift */; }; D080B0A77D9844C3A0737170 /* IntegrationConnectionFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FB91B577BC77F45EDB2AAAA /* IntegrationConnectionFormViewModel.swift */; }; D2B95B92F8F84F2FA4B41BF6 /* BookEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B33D03D266EF46B5B02FBF40 /* BookEntity.swift */; }; @@ -895,14 +990,19 @@ D8886C9E9E2A183454C625CC /* VideoFullscreenViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78387C7EDD9105363712F34 /* VideoFullscreenViewController.swift */; }; DCFC79BEBAEA628FB13F33A1 /* BPFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACFA02FA31A0CB0438C5228 /* BPFont.swift */; }; E101E3603ACF4DB50AA0037B /* JellyfinQuickConnectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B8DAE18EF12485AD3509B2D /* JellyfinQuickConnectTests.swift */; }; + ED1900FB7892C8FA42A5A665 /* UUIDMigrationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F3C1F62F5F873500AC91A2 /* UUIDMigrationPolicy.swift */; }; + EF5A25679D73F58A5D551D01 /* IntegrationConnectionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587EDC7DEA9D2985635F1161 /* IntegrationConnectionStore.swift */; }; F2535BF980A548BFB9D3E8C1 /* VideoPiPCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2EA56C448451F969BC0E2AB /* VideoPiPCoordinator.swift */; }; F50163BEFB92FB6B1B778C5E /* WindowHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB8BFBB9A63469069F0D44A /* WindowHelper.swift */; }; + F575EA7BBE6C619D27225281 /* IntegrationConnectionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587EDC7DEA9D2985635F1161 /* IntegrationConnectionStore.swift */; }; F6348C04290CD2CE7BE3F015 /* IntegrationConnectionDataDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB8BD2F5EC900DC51D5D02D /* IntegrationConnectionDataDecodingTests.swift */; }; - F7CB281CF765C468BFA9B0D8 /* IntegrationDetailsViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808E351A8B30ABCBA9EC00EF /* IntegrationDetailsViewModelProtocol.swift */; }; F906EF4FC85B1CCE138B230D /* PasskeyEmailInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3373A34FC2536111E50BF868 /* PasskeyEmailInputView.swift */; }; F97E68221AB7CDFB93CC0AF7 /* AudiobookShelfConnectionViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638A9B40CE7EECEF80FA9935 /* AudiobookShelfConnectionViewModelTests.swift */; }; F9FA95F9A4AE359006A85B88 /* PreferencesAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E6918C51C370C2E89B24F9 /* PreferencesAPI.swift */; }; + FAE65B9ED5048BF9DD783BEF /* AudiobookShelfOIDCFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75010F6AB433BBEEB7FA9376 /* AudiobookShelfOIDCFlow.swift */; }; + FBE2B360883B414695A54628 /* ReviewPromptService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C80A51B636D4C7381529505 /* ReviewPromptService.swift */; }; FED52673ACD975949EF9E219 /* PreferencesSyncServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EA09FE5A35CEA0B87F83EF3 /* PreferencesSyncServiceTests.swift */; }; + FED6216CFABF04A613866335 /* WebAuthenticating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377C4C967D9AE1D471C2207C /* WebAuthenticating.swift */; }; FEEDBABE0000000000000002 /* SharedWidgetStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDBABE0000000000000001 /* SharedWidgetStore.swift */; }; FEEDBABE0000000000000003 /* SharedWidgetStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDBABE0000000000000001 /* SharedWidgetStore.swift */; }; FEEDBABE0000000000000005 /* SharedWidgetStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDBABE0000000000000004 /* SharedWidgetStoreTests.swift */; }; @@ -1063,16 +1163,23 @@ 12EDB8401F38342155CBBC91 /* mp3_NO_chapters.mp3 */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.mp3; path = mp3_NO_chapters.mp3; sourceTree = ""; }; 13C407B5BD19D3CF9CC64EC7 /* IntegrationLibraryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationLibraryView.swift; sourceTree = ""; }; 1BA32C89144C68DD64FD0B62 /* mp3_NO_toc_v23.mp3 */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.mp3; path = mp3_NO_toc_v23.mp3; sourceTree = ""; }; + 1C6581B92E4D292C2AFC361A /* SyncResponseFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncResponseFixtures.swift; sourceTree = ""; }; 1FFF7B7B6E62B274D230C207 /* IntegrationServerAddress.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationServerAddress.swift; sourceTree = ""; }; 22E85F55E03D1981F097661C /* PKCE.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PKCE.swift; sourceTree = ""; }; 269BB56618B150DCAA2CA5FB /* IntegrationConnectionStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationConnectionStoreTests.swift; sourceTree = ""; }; + 2AF448F75392B02FC5451289 /* SortIntegrationHarness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortIntegrationHarness.swift; sourceTree = ""; }; 2BB8BD2F5EC900DC51D5D02D /* IntegrationConnectionDataDecodingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationConnectionDataDecodingTests.swift; sourceTree = ""; }; 2E070421575334E1CD85C049 /* IntegrationLibraryViewModelProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationLibraryViewModelProtocol.swift; sourceTree = ""; }; 3373A34FC2536111E50BF868 /* PasskeyEmailInputView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PasskeyEmailInputView.swift; sourceTree = ""; }; + 3451CA062919E03CEB975E92 /* IntegrationHostResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationHostResolver.swift; sourceTree = ""; }; + A51357B087F7FB3CF6E08296 /* ExternalStreamResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExternalStreamResolver.swift; sourceTree = ""; }; + 97ADE40DF29059A5B97E37D6 /* ExternalProgressProviding.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExternalProgressProviding.swift; sourceTree = ""; }; + C85605CDB6C6075AADBE2D15 /* ExternalProgressService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExternalProgressService.swift; sourceTree = ""; }; 35C564A7BDE3A2D98E02BF8B /* IntegrationConnectedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationConnectedView.swift; sourceTree = ""; }; 3724BB21B1B88177C60BE4D8 /* IntegrationConnectionFormViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationConnectionFormViewModelProtocol.swift; sourceTree = ""; }; 377C4C967D9AE1D471C2207C /* WebAuthenticating.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebAuthenticating.swift; sourceTree = ""; }; 3ACFA02FA31A0CB0438C5228 /* BPFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPFont.swift; sourceTree = ""; }; + 3C80A51B636D4C7381529505 /* ReviewPromptService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPromptService.swift; sourceTree = ""; }; 3F6640892E162ABF00356522 /* AudioMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMetadataService.swift; sourceTree = ""; }; 3F66408C2E172DF500356522 /* MappingModel_v9_to_v10.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = MappingModel_v9_to_v10.xcmappingmodel; sourceTree = ""; }; 3F6640932E17386400356522 /* DeleteUserBookData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteUserBookData.swift; sourceTree = ""; }; @@ -1082,6 +1189,7 @@ 3F7B643C2E0FA0B800299D97 /* GraphQLClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphQLClient.swift; sourceTree = ""; }; 3F7B64412E0FB27000299D97 /* AnyCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyCodable.swift; sourceTree = ""; }; 3F7B64442E0FB29600299D97 /* BooksData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BooksData.swift; sourceTree = ""; }; + BDE1A8E1D323493B91FA8E1C /* BookByIdData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookByIdData.swift; sourceTree = ""; }; 3F7B64462E0FB2A900299D97 /* AudiobookEditionsData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudiobookEditionsData.swift; sourceTree = ""; }; 3F7B64482E0FB2C100299D97 /* InsertUserBookData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertUserBookData.swift; sourceTree = ""; }; 3FA43BF72E110B4400DF9B1F /* SimpleHardcoverBook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleHardcoverBook.swift; sourceTree = ""; }; @@ -1169,6 +1277,10 @@ 4161D496245660B800D1C01B /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; 4163E3102148606600072AA2 /* ImportFileItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportFileItem.swift; sourceTree = ""; }; 4163E312214AC43000072AA2 /* ImportOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportOperationTests.swift; sourceTree = ""; }; + 65962B5F03D221629A83C22C /* RefreshTaskOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshTaskOperationTests.swift; sourceTree = ""; }; + 098A98893A8783E8BE1C5420 /* ListSyncRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListSyncRefreshServiceTests.swift; sourceTree = ""; }; + EDC06443EFD052E697036798 /* QueuedTaskDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueuedTaskDisplayTests.swift; sourceTree = ""; }; + 5359C57600F2E373E8F33D39 /* NetworkClientQueryEncodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkClientQueryEncodingTests.swift; sourceTree = ""; }; 4163E314214ACB4300072AA2 /* DataTestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataTestUtils.swift; sourceTree = ""; }; 41640A1F24416774004FB97B /* IntentsUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IntentsUI.framework; path = System/Library/Frameworks/IntentsUI.framework; sourceTree = SDKROOT; }; 41640A3624416EE8004FB97B /* Intents.intentdefinition */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; path = Intents.intentdefinition; sourceTree = ""; }; @@ -1189,6 +1301,7 @@ 416AAC3023F44A88005AD04F /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 416AAC3223F51031005AD04F /* LocalizableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizableButton.swift; sourceTree = ""; }; 416B63A72146354B007D04B1 /* ImportManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportManager.swift; sourceTree = ""; }; + E00DA9C0FECB434386459560 /* ImportConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportConfirmationView.swift; sourceTree = ""; }; 416B640C1E72F362000E955A /* Notification+BookPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Notification+BookPlayer.swift"; sourceTree = ""; }; 416BF8A526537A4C00239166 /* BookPlayer-NoCarPlay.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "BookPlayer-NoCarPlay.entitlements"; sourceTree = ""; }; 416E40F32404BD5C006FA2EF /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.stringsdict"; sourceTree = ""; }; @@ -1320,11 +1433,15 @@ 465D87532D31965100A4AA47 /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = ""; }; 4BB8BFBB9A63469069F0D44A /* WindowHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowHelper.swift; sourceTree = ""; }; 4BFC5736E667F4BF8CBB9096 /* mp3_NO_toc_v24.mp3 */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.mp3; path = mp3_NO_toc_v24.mp3; sourceTree = ""; }; + 4C78CCAAFEB5A9D78AA108EC /* QueryTimeSortRefactorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryTimeSortRefactorTests.swift; sourceTree = ""; }; 4D91D6855100BD1823B2674F /* IntegrationConnectionViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationConnectionViewModelProtocol.swift; sourceTree = ""; }; 4D9D68F735D2519CDBC18495 /* IntegrationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationSettingsView.swift; sourceTree = ""; }; 5126F120258E9F18009965DC /* URL+BookPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URL+BookPlayer.swift"; sourceTree = ""; }; 5265B16421BFC176BFFE4EA5 /* IntegrationHTTPClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationHTTPClient.swift; sourceTree = ""; }; + 53AE3D4C955A493470C8EAA9 /* QueryTimeSortSpikeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryTimeSortSpikeTests.swift; sourceTree = ""; }; + 56AC45C580EED027394A645B /* MediaServerProvider+Icon.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "MediaServerProvider+Icon.swift"; sourceTree = ""; }; 587EDC7DEA9D2985635F1161 /* IntegrationConnectionStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationConnectionStore.swift; sourceTree = ""; }; + 5BE48B26D3726D5519F45ED9 /* IntegrationHostResolverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationHostResolverTests.swift; sourceTree = ""; }; 5C6BC9E7E76B9BAB7A0D88D2 /* SortPreferencesResolving.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SortPreferencesResolving.swift; sourceTree = ""; }; 5CBB29522163A17F00E3A9FF /* ZIPFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ZIPFoundation.framework; path = Carthage/Build/iOS/ZIPFoundation.framework; sourceTree = ""; }; 5F847DAE94054533AACF8D85 /* PlayBookIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayBookIntent.swift; sourceTree = ""; }; @@ -1335,11 +1452,6 @@ 624EB9832755D8E700D462A8 /* Audiobook Player 8.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Audiobook Player 8.xcdatamodel"; sourceTree = ""; }; 62AAE22B274AA3EB001EB9FF /* LibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryService.swift; sourceTree = ""; }; 62AAE230274ABB5D001EB9FF /* LibraryServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryServiceTests.swift; sourceTree = ""; }; - 4C78CCAAFEB5A9D78AA108EC /* QueryTimeSortRefactorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryTimeSortRefactorTests.swift; sourceTree = ""; }; - 1C6581B92E4D292C2AFC361A /* SyncResponseFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncResponseFixtures.swift; sourceTree = ""; }; - 2AF448F75392B02FC5451289 /* SortIntegrationHarness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortIntegrationHarness.swift; sourceTree = ""; }; - 53AE3D4C955A493470C8EAA9 /* QueryTimeSortSpikeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryTimeSortSpikeTests.swift; sourceTree = ""; }; - F448E8BBF01860FC65EB697A /* SortCharacterizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortCharacterizationTests.swift; sourceTree = ""; }; 62CADB8D2724E41800A4A98F /* Audiobook Player 7.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Audiobook Player 7.xcdatamodel"; sourceTree = ""; }; 62F2F25E25E18C7500E1D6A0 /* ImportableItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportableItem.swift; sourceTree = ""; }; 63005A432AE7FD8000A4CA2C /* BookPlayerWidgetsWatch.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BookPlayerWidgetsWatch.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1464,6 +1576,7 @@ 636086032C5B589900341D78 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = ""; }; 636086042C5B589900341D78 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/AppShortcuts.strings; sourceTree = ""; }; 63652C722E6E8A9700231202 /* ChaptersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChaptersView.swift; sourceTree = ""; }; + 63673F95304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = MappingModel_v11_to_v12.xcmappingmodel; sourceTree = ""; }; 63682CBD2E2F422900C15FC1 /* DebugFileTransferable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugFileTransferable.swift; sourceTree = ""; }; 636E77882DF4C76600D4DC0A /* JellyfinRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinRootView.swift; sourceTree = ""; }; 636ED1E42D51254E00BFF3FD /* TipOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipOption.swift; sourceTree = ""; }; @@ -1532,7 +1645,6 @@ 638761D22E62BD7A009332E8 /* ItemListSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListSelectionView.swift; sourceTree = ""; }; 638A9B40CE7EECEF80FA9935 /* AudiobookShelfConnectionViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudiobookShelfConnectionViewModelTests.swift; sourceTree = ""; }; 638D91A72E60AAA600B62BDD /* ImportOperationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportOperationState.swift; sourceTree = ""; }; - 638E64CD2B8E1CFD00DCFA3B /* SyncTasksCountService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTasksCountService.swift; sourceTree = ""; }; 6397206E2CA71D600045A4DB /* .swift-format */ = {isa = PBXFileReference; lastKnownFileType = text; path = ".swift-format"; sourceTree = ""; }; 639720712CAAF8290045A4DB /* LastPlayedProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastPlayedProvider.swift; sourceTree = ""; }; 639720822CAB0C380045A4DB /* LastPlayedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastPlayedView.swift; sourceTree = ""; }; @@ -1575,6 +1687,13 @@ 63B4A3C32E93749300784A22 /* DynamicAccessibilityLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicAccessibilityLabel.swift; sourceTree = ""; }; 63B4A3C52E93858C00784A22 /* PlayerControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsView.swift; sourceTree = ""; }; 63B50F042B692E4200BCABBA /* ListSyncRefreshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListSyncRefreshService.swift; sourceTree = ""; }; + 185FB83C0CF083AC102D8753 /* SyncPauseReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPauseReporter.swift; sourceTree = ""; }; + 7DAECA780F8170162DAD1492 /* UploadContinuationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadContinuationController.swift; sourceTree = ""; }; + 5EEA5B438944574494C5385F /* BackgroundSessionWakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundSessionWakeCoordinator.swift; sourceTree = ""; }; + 42B6A047D0689D9E2E2D9749 /* PromptSurfaceArbiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptSurfaceArbiter.swift; sourceTree = ""; }; + 3D4833D717C7B97F7B5DFDC5 /* PromptSurfaceArbiterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptSurfaceArbiterTests.swift; sourceTree = ""; }; + F3B31C1E62A380C33C60FF9B /* PlaybackAlerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackAlerts.swift; sourceTree = ""; }; + F05D3B1199FC741AB2FDED67 /* PlaybackFailure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackFailure.swift; sourceTree = ""; }; 63B760E72C31FFC600AA98C7 /* SupportFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportFlowCoordinator.swift; sourceTree = ""; }; 63B760F62C32734000AA98C7 /* SecondOnboardingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondOnboardingResponse.swift; sourceTree = ""; }; 63B760F82C32738E00AA98C7 /* StoryAccountSubscriptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryAccountSubscriptionService.swift; sourceTree = ""; }; @@ -1605,7 +1724,6 @@ 63C48D532E50AAA1005FBB96 /* AccountPasskeySectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPasskeySectionView.swift; sourceTree = ""; }; 63C6C2E52B5029BC00FFE0D8 /* SettingsAutolockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAutolockView.swift; sourceTree = ""; }; 63C6C2E72B5029FE00FFE0D8 /* SettingsAutolockViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAutolockViewModel.swift; sourceTree = ""; }; - 63C6C30B2B538B7A00FFE0D8 /* SyncTasksStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTasksStorage.swift; sourceTree = ""; }; 63C6C30F2B54F14800FFE0D8 /* LibraryItemSyncOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryItemSyncOperation.swift; sourceTree = ""; }; 63C6C3172B5E0FE700FFE0D8 /* SyncTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTask.swift; sourceTree = ""; }; 63CD851C2CE2964900EDBEA8 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -1622,6 +1740,7 @@ 63E15DA32D932695005B42E5 /* SleepTimerCommandsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepTimerCommandsView.swift; sourceTree = ""; }; 63E1FF8C2E6C835400F2E003 /* ItemDetailsFooterSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDetailsFooterSectionView.swift; sourceTree = ""; }; 63E1FF8E2E6C848200F2E003 /* ItemDetailsTitleSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDetailsTitleSectionView.swift; sourceTree = ""; }; + F45535E22CB7445EB5DCAA5F /* ItemDetailsExternalResourceSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDetailsExternalResourceSectionView.swift; sourceTree = ""; }; 63E1FF902E6C8C2000F2E003 /* SyncJobType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncJobType.swift; sourceTree = ""; }; 63E54C312D494E110040355D /* RemoteItemListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteItemListViewModel.swift; sourceTree = ""; }; 63E54C332D50576C0040355D /* TipJarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipJarView.swift; sourceTree = ""; }; @@ -1644,9 +1763,8 @@ 6906A54F21720FDF00A9E0B2 /* BookSortServiceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSortServiceTest.swift; sourceTree = ""; }; 6906A552217211C600A9E0B2 /* StubFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubFactory.swift; sourceTree = ""; }; 69343D322133844D000C425E /* VoiceOverService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceOverService.swift; sourceTree = ""; }; - 3C80A51B636D4C7381529505 /* ReviewPromptService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPromptService.swift; sourceTree = ""; }; - 6F83542FF9A240EC87E55AC8 /* ReviewPromptServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPromptServiceTests.swift; sourceTree = ""; }; 69343D35213A07B4000C425E /* VoiceOverServiceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceOverServiceTest.swift; sourceTree = ""; }; + 6F83542FF9A240EC87E55AC8 /* ReviewPromptServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPromptServiceTests.swift; sourceTree = ""; }; 6FB91B577BC77F45EDB2AAAA /* IntegrationConnectionFormViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationConnectionFormViewModel.swift; sourceTree = ""; }; 700DFD61111AC10253D6EBAA /* SyncTasksStorageTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncTasksStorageTests.swift; sourceTree = ""; }; 75010F6AB433BBEEB7FA9376 /* AudiobookShelfOIDCFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudiobookShelfOIDCFlow.swift; sourceTree = ""; }; @@ -1677,6 +1795,21 @@ 8D3EFD93E6D1E0366A3FD1E9 /* mp3_WITH_toc.mp3 */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.mp3; path = mp3_WITH_toc.mp3; sourceTree = ""; }; 8DA0C0229963991F8CE1E941 /* IntegrationConnectionFlowView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationConnectionFlowView.swift; sourceTree = ""; }; 8FBF775DDE66D0AA08818D45 /* LibraryOptionsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LibraryOptionsView.swift; sourceTree = ""; }; + 9904420F2F8891F9007E2A44 /* MediaServersLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaServersLoginView.swift; sourceTree = ""; }; + 9911FBAB2F71CD5F00DC9A2B /* AsyncOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncOperation.swift; sourceTree = ""; }; + 9911FBAE2F71D03100DC9A2B /* SyncQueueService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncQueueService.swift; sourceTree = ""; }; + 9911FBB12F71E54600DC9A2B /* SyncQueueRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncQueueRepository.swift; sourceTree = ""; }; + 9911FBB42F72486400DC9A2B /* ExternalUpdateProgressOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalUpdateProgressOperation.swift; sourceTree = ""; }; + 9911FBB72F72F46200DC9A2B /* QueuedSyncTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueuedSyncTask.swift; sourceTree = ""; }; + 099268746F86BD2193897F88 /* TaskPause.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskPause.swift; sourceTree = ""; }; + 9911FBBD2F74526F00DC9A2B /* SyncQueueProgressMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncQueueProgressMonitor.swift; sourceTree = ""; }; + 25C877CDD6114F67F0CD2AFC /* QueueCounts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueCounts.swift; sourceTree = ""; }; + 9911FBC32F76121C00DC9A2B /* FileUploadOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadOperation.swift; sourceTree = ""; }; + 7CF4215FDCB8C2EA0F4A975D /* PartUploadTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PartUploadTransport.swift; sourceTree = ""; }; + 9911FBC62F7738C100DC9A2B /* SyncableExternalResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncableExternalResource.swift; sourceTree = ""; }; + 9911FBD62F7CACE200DC9A2B /* QueuedTasksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueuedTasksView.swift; sourceTree = ""; }; + 4F26B24460975E0B0B15ABDA /* QueuedTaskDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueuedTaskDisplay.swift; sourceTree = ""; }; + FCC4219B7DA1CD232AE12888 /* SyncPauseReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPauseReport.swift; sourceTree = ""; }; 99329DB62F3AA8F6003F8E73 /* PlayControlsRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayControlsRowView.swift; sourceTree = ""; }; 99329DBA2F3AAA61003F8E73 /* ListeningProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningProgressView.swift; sourceTree = ""; }; 99329DBC2F3AAAB4003F8E73 /* NavigationRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRowView.swift; sourceTree = ""; }; @@ -1694,14 +1827,24 @@ 99329DD62F3E8A04003F8E73 /* SlickSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlickSlider.swift; sourceTree = ""; }; 99329DDA2F3ED5E5003F8E73 /* DurationPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationPickerSheet.swift; sourceTree = ""; }; 995787DD2F56674400BF0814 /* Audiobook Player 11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Audiobook Player 11.xcdatamodel"; sourceTree = ""; }; + 996D4CC42F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = MappingModel_v10_to_v11.xcmappingmodel; sourceTree = ""; }; 99B367F92F4575C600C38F7F /* MediaAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaAction.swift; sourceTree = ""; }; 99B367FB2F5108BF00C38F7F /* MediaActionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaActionRow.swift; sourceTree = ""; }; + 99D995942F71ABB700BAF216 /* JellyfinAudiobookDetailsData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAudiobookDetailsData.swift; sourceTree = ""; }; 99F3C1EB2F5BCE1000AC91A2 /* LibraryItemRef.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryItemRef.swift; sourceTree = ""; }; 99F3C1F62F5F873500AC91A2 /* UUIDMigrationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDMigrationPolicy.swift; sourceTree = ""; }; 99F3C1FD2F5FC2E600AC91A2 /* SchemaV1SyncTasksModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaV1SyncTasksModels.swift; sourceTree = ""; }; 99F3C2032F5FC76700AC91A2 /* MigrationPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationPlan.swift; sourceTree = ""; }; 99F3C2062F5FD40C00AC91A2 /* SchemaV2SyncTasksModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaV2SyncTasksModels.swift; sourceTree = ""; }; 99F3C2092F62285500AC91A2 /* ApiResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiResponse.swift; sourceTree = ""; }; + 99F3C20C2F64ED1900AC91A2 /* Audiobook Player 12.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Audiobook Player 12.xcdatamodel"; sourceTree = ""; }; + 99F3C20D2F650EEB00AC91A2 /* ExternalResource+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ExternalResource+CoreDataClass.swift"; sourceTree = ""; }; + 99F3C2102F650F0C00AC91A2 /* ExternalResource+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ExternalResource+CoreDataProperties.swift"; sourceTree = ""; }; + 99F3C2132F65174000AC91A2 /* SimpleExternalResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleExternalResource.swift; sourceTree = ""; }; + 99F3C21E2F69D0AC00AC91A2 /* ExternalImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalImportView.swift; sourceTree = ""; }; + 99F3C2202F69D32800AC91A2 /* ExternalImportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalImportViewModel.swift; sourceTree = ""; }; + AA1842BD89ED97A5DC8C0577 /* ExternalImportEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalImportEvents.swift; sourceTree = ""; }; + 99F3C2402F6E322000AC91A2 /* SchemaV3SyncTasksModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaV3SyncTasksModels.swift; sourceTree = ""; }; 9F00A5F9294F8BFE005EA316 /* ClearableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearableTextField.swift; sourceTree = ""; }; 9F00A5FB294F9B9A005EA316 /* ItemDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDetailsView.swift; sourceTree = ""; }; 9F00A5FF295001C0005EA316 /* ItemDetailsArtworkSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDetailsArtworkSectionView.swift; sourceTree = ""; }; @@ -1729,7 +1872,7 @@ 9F2DC9D82A008B19006CDF1F /* PricingOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PricingOptionsView.swift; sourceTree = ""; }; 9F2DC9DA2A008B28006CDF1F /* PricingRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PricingRowView.swift; sourceTree = ""; }; 9F2DC9E32A01313C006CDF1F /* PricingModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PricingModel.swift; sourceTree = ""; }; - 9F2E00912A568F800001FE20 /* IdentifiersResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifiersResponse.swift; sourceTree = ""; }; + 9F2E00912A568F800001FE20 /* ItemsStatusResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsStatusResponse.swift; sourceTree = ""; }; 9F2E00DD2A5C2B810001FE20 /* StorageCloudDeletedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageCloudDeletedViewModel.swift; sourceTree = ""; }; 9F3C4357284132640066D99A /* AlertPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPresenter.swift; sourceTree = ""; }; 9F3C435B28413C070066D99A /* DataInitializerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataInitializerCoordinator.swift; sourceTree = ""; }; @@ -1796,6 +1939,7 @@ 9FBDBC782879409600D315A2 /* SyncableItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncableItem.swift; sourceTree = ""; }; 9FBDBC7C287940D900D315A2 /* ContentsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentsResponse.swift; sourceTree = ""; }; 9FBDBC7F2879C6C900D315A2 /* UploadItemResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItemResponse.swift; sourceTree = ""; }; + 1713BB63A647FA8447545EFD /* MultipartUploadResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipartUploadResponse.swift; sourceTree = ""; }; 9FBF33EB29F6293C00501639 /* SimpleBookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleBookmark.swift; sourceTree = ""; }; 9FC1E4492814C4DE00522FA8 /* NetworkClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkClient.swift; sourceTree = ""; }; 9FC1E4582814E0B000522FA8 /* NetworkUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkUtils.swift; sourceTree = ""; }; @@ -1815,7 +1959,6 @@ 9FE786AD292D0FA8002B4A8D /* BookPlayerShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BookPlayerShareExtension.entitlements; sourceTree = ""; }; 9FE86B8D2A544CB200B5450E /* Binding+BookPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Binding+BookPlayer.swift"; sourceTree = ""; }; 9FF383D02A40F97000BBAC11 /* MappingModel_v8_to_v9.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = MappingModel_v8_to_v9.xcmappingmodel; sourceTree = ""; }; - 9FF710B62A212A19006490E0 /* QueuedSyncTasksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueuedSyncTasksView.swift; sourceTree = ""; }; 9FF710B82A213084006490E0 /* QueuedSyncTaskRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueuedSyncTaskRowView.swift; sourceTree = ""; }; 9FF710BC2A215686006490E0 /* QueuedSyncTaskType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueuedSyncTaskType.swift; sourceTree = ""; }; 9FFCC08E289418CA00F4952E /* SimpleChapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleChapter.swift; sourceTree = ""; }; @@ -1861,12 +2004,20 @@ EC40E520F644AFA46DAD76B0 /* AudioMetadataServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudioMetadataServiceTests.swift; sourceTree = ""; }; F1E6918C51C370C2E89B24F9 /* PreferencesAPI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PreferencesAPI.swift; sourceTree = ""; }; F4388D9707FA7C85F9D07E75 /* WebAuthenticatingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebAuthenticatingTests.swift; sourceTree = ""; }; + F448E8BBF01860FC65EB697A /* SortCharacterizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortCharacterizationTests.swift; sourceTree = ""; }; F496A2BF4CBFFC745C453D42 /* ChaptersViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChaptersViewModelTests.swift; sourceTree = ""; }; F5A989BA90E16F60920A1DB0 /* PKCETests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PKCETests.swift; sourceTree = ""; }; F6F0D505E060FDAEB6DB68E3 /* IntegrationLibraryListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IntegrationLibraryListView.swift; sourceTree = ""; }; F78387C7EDD9105363712F34 /* VideoFullscreenViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFullscreenViewController.swift; sourceTree = ""; }; FEEDBABE0000000000000001 /* SharedWidgetStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedWidgetStore.swift; sourceTree = ""; }; FEEDBABE0000000000000004 /* SharedWidgetStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedWidgetStoreTests.swift; sourceTree = ""; }; + FF75FEB4DEC37DBE232CD140 /* SyncQueueTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncQueueTests.swift; sourceTree = ""; }; + 04EDE7B20E949F916D8ECA2D /* UploadContinuationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UploadContinuationTests.swift; sourceTree = ""; }; + 60EBB5E9BC0C94F511BD4044 /* MissingItemsPassTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MissingItemsPassTests.swift; sourceTree = ""; }; + 83C482DCC8DC2DF811A3C7BC /* BackgroundSessionWakeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BackgroundSessionWakeTests.swift; sourceTree = ""; }; + 5C5F08C368941D45696C7891 /* MultipartUploadEngineTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MultipartUploadEngineTests.swift; sourceTree = ""; }; + C64E54AA0319D8F687DFB6E4 /* SyncPauseReportingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncPauseReportingTests.swift; sourceTree = ""; }; + B2339A98D9659EA78CB738CF /* TasksDataManagerStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TasksDataManagerStoreTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1890,8 +2041,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 9907757E2FF33C1500E35FAE /* Kingfisher in Frameworks */, + 990775792FF33BE100E35FAE /* Get in Frameworks */, 9F00F6EA2807B5D300996BBB /* RevenueCat in Frameworks */, - 62CADBAA2725BCF000A4A98F /* Kingfisher in Frameworks */, + 99D9959A2F71B05700BAF216 /* JellyfinAPI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1918,6 +2071,7 @@ 41F1A1D7254B07E80043FCF3 /* MarqueeLabel in Frameworks */, 8A495CC62CC85D27001B4244 /* JellyfinAPI in Frameworks */, 41F1A228254B0C6C0043FCF3 /* ZipArchive in Frameworks */, + 990775802FF33D5300E35FAE /* Get in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1932,8 +2086,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 9907757C2FF33C0C00E35FAE /* Kingfisher in Frameworks */, + 990775772FF33BD700E35FAE /* Get in Frameworks */, 9F00F6E82807B5CB00996BBB /* RevenueCat in Frameworks */, - 62CADBA82725BCE800A4A98F /* Kingfisher in Frameworks */, + 99D995982F71B02D00BAF216 /* JellyfinAPI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2059,6 +2215,7 @@ 3F7B64412E0FB27000299D97 /* AnyCodable.swift */, 3F7B64462E0FB2A900299D97 /* AudiobookEditionsData.swift */, 3F7B64442E0FB29600299D97 /* BooksData.swift */, + BDE1A8E1D323493B91FA8E1C /* BookByIdData.swift */, 3F6640932E17386400356522 /* DeleteUserBookData.swift */, 3F7B64482E0FB2C100299D97 /* InsertUserBookData.swift */, ); @@ -2268,6 +2425,7 @@ 9F4A33DE292B0C710034CD4C /* BookPlayerShareExtension */, 418B6CF91D2707F800F974FB /* Products */, 41AABFFA1E705D18006BD2E0 /* Frameworks */, + 993BAADC2F9894D800FB8014 /* Recovered References */, ); indentWidth = 2; sourceTree = ""; @@ -2341,6 +2499,11 @@ 69343D34213A079B000C425E /* Services */, 63833DF82AAC134600496246 /* PerformanceTests */, 4163E312214AC43000072AA2 /* ImportOperationTests.swift */, + 3D4833D717C7B97F7B5DFDC5 /* PromptSurfaceArbiterTests.swift */, + 65962B5F03D221629A83C22C /* RefreshTaskOperationTests.swift */, + 098A98893A8783E8BE1C5420 /* ListSyncRefreshServiceTests.swift */, + EDC06443EFD052E697036798 /* QueuedTaskDisplayTests.swift */, + 5359C57600F2E373E8F33D39 /* NetworkClientQueryEncodingTests.swift */, 41C3CF7D20AEA5E5007C3EF4 /* DataManagerTests.swift */, 9F8A9A5D27AC3F8C0093AA1C /* PlayableItemTests.swift */, 689E2BCD29DC1D9253949A53 /* PlayableChapterTests.swift */, @@ -2367,6 +2530,9 @@ 41A1B0F1226E9C5100EA0400 /* Shared */ = { isa = PBXGroup; children = ( + 996D4CD02F98A41E00FC3B57 /* Integrations */, + 99D9958B2F719ECF00BAF216 /* Jellyfin */, + 992D6E112FA98AEF0091849F /* AudiobookShelf */, 9F22DE3A288D771800056FCD /* Styleguide */, 9FC1E4572814E09700522FA8 /* Network */, 62AAE22F274AA6E9001EB9FF /* Services */, @@ -2458,9 +2624,11 @@ 41C8ABCF26836F03003B67D1 /* Import */ = { isa = PBXGroup; children = ( + 99F3C21D2F69D09A00AC91A2 /* ExternalSource */, 62793612272CC19E0097837D /* Models */, 62793613272CC9D50097837D /* Views */, 416B63A72146354B007D04B1 /* ImportManager.swift */, + E00DA9C0FECB434386459560 /* ImportConfirmationView.swift */, 4197FAFB267E480100811CC8 /* ImportViewController.swift */, 41C8ABD026836F50003B67D1 /* ImportViewModel.swift */, ); @@ -2480,7 +2648,8 @@ 41A359C2276232E00020D5F5 /* MappingModel_v7_to_v8.xcmappingmodel */, 9FF383D02A40F97000BBAC11 /* MappingModel_v8_to_v9.xcmappingmodel */, 3F66408C2E172DF500356522 /* MappingModel_v9_to_v10.xcmappingmodel */, - 6321324F2F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel */, + 996D4CC42F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel */, + 63673F95304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel */, ); path = Migrations; sourceTree = ""; @@ -2588,7 +2757,6 @@ 8B8DAE18EF12485AD3509B2D /* JellyfinQuickConnectTests.swift */, 623E13D0C96857091397B5EF /* IntegrationServerAddressTests.swift */, ); - name = MediaServerIntegration; path = MediaServerIntegration; sourceTree = ""; }; @@ -2628,6 +2796,7 @@ 41546FF026F54F0600825180 /* PlaybackState.swift */, 9FBF33EB29F6293C00501639 /* SimpleBookmark.swift */, 9FFCC08E289418CA00F4952E /* SimpleChapter.swift */, + 99F3C2132F65174000AC91A2 /* SimpleExternalResource.swift */, 3FA43BF72E110B4400DF9B1F /* SimpleHardcoverBook.swift */, 41546FF226F559CE00825180 /* SimpleItemType.swift */, 41188D3026ED715D0017124E /* SimpleLibraryItem.swift */, @@ -2651,6 +2820,8 @@ 4138CE1526E584B60014F11E /* BookmarkType.swift */, 41A1B111226F88C500EA0400 /* Chapter+CoreDataClass.swift */, 41A1B112226F88C500EA0400 /* Chapter+CoreDataProperties.swift */, + 99F3C20D2F650EEB00AC91A2 /* ExternalResource+CoreDataClass.swift */, + 99F3C2102F650F0C00AC91A2 /* ExternalResource+CoreDataProperties.swift */, 41A1B11B226F88C500EA0400 /* Folder+CoreDataClass.swift */, 41A1B11C226F88C500EA0400 /* Folder+CoreDataProperties.swift */, 3FF5BA682E10EA8000B82BCC /* HardcoverBook+CoreDataClass.swift */, @@ -2713,6 +2884,8 @@ 9FD8FE4C286566FF00EB2C3D /* Sync */, 64957AF0989CEBEEEC40E4BB /* Preferences */, FEEDBABE0000000000000001 /* SharedWidgetStore.swift */, + 9911FBAA2F71CD1600DC9A2B /* SyncQueue */, + D95DF238A734AE606D62DADB /* ExternalProgress */, ); path = Services; sourceTree = ""; @@ -2880,8 +3053,8 @@ 636E77872DF4948F00D4DC0A /* Details */ = { isa = PBXGroup; children = ( - 8ADD46252CF67587002E9C50 /* JellyfinAudiobookDetailsViewModel.swift */, 8ADD46232CF38F91002E9C50 /* JellyfinAudiobookDetailsView.swift */, + 8ADD46252CF67587002E9C50 /* JellyfinAudiobookDetailsViewModel.swift */, ); path = Details; sourceTree = ""; @@ -2942,6 +3115,7 @@ 638352C62E67F327009EE79E /* Models */ = { isa = PBXGroup; children = ( + 99F3C2402F6E322000AC91A2 /* SchemaV3SyncTasksModels.swift */, 99F3C2062F5FD40C00AC91A2 /* SchemaV2SyncTasksModels.swift */, 99F3C1FD2F5FC2E600AC91A2 /* SchemaV1SyncTasksModels.swift */, 638352C52E67F327009EE79E /* SyncTasksModels.swift */, @@ -2972,7 +3146,6 @@ 638487972EC7722400DF442B /* Details */ = { isa = PBXGroup; children = ( - 638487962EC7722400DF442B /* AudiobookShelfAudiobookDetailsData.swift */, 638487CA2EC77B1200DF442B /* AudiobookShelfAudiobookDetailsView.swift */, 638487CB2EC77B1200DF442B /* AudiobookShelfAudiobookDetailsViewModel.swift */, ); @@ -3001,8 +3174,6 @@ 638487972EC7722400DF442B /* Details */, 638487982EC7722400DF442B /* GridLayout */, 638487992EC7722400DF442B /* ListLayout */, - 6384879A2EC7722400DF442B /* AudiobookShelfLibraryItem.swift */, - 638487B62EC7752000DF442B /* AudiobookShelfLibraryLevelData.swift */, 638487C02EC7767000DF442B /* AudiobookShelfLibraryItemImageView.swift */, 638487C12EC7767000DF442B /* AudiobookShelfLibraryView.swift */, 638487C22EC7767000DF442B /* AudiobookShelfLibraryViewModel.swift */, @@ -3013,10 +3184,6 @@ 638487A02EC7722400DF442B /* Network */ = { isa = PBXGroup; children = ( - 6384879C2EC7722400DF442B /* AudiobookShelfConnectionData.swift */, - 6384879D2EC7722400DF442B /* AudiobookShelfConnectionService.swift */, - 6384879F2EC7722400DF442B /* AudiobookShelfLibrary.swift */, - 75010F6AB433BBEEB7FA9376 /* AudiobookShelfOIDCFlow.swift */, ); path = Network; sourceTree = ""; @@ -3212,6 +3379,11 @@ 4193E201243A91AE004D6A82 /* ActionParserService.swift */, 9F82DF9B27DFE46B001B0EA8 /* PhoneWatchConnectivityService.swift */, 63B50F042B692E4200BCABBA /* ListSyncRefreshService.swift */, + 185FB83C0CF083AC102D8753 /* SyncPauseReporter.swift */, + 7DAECA780F8170162DAD1492 /* UploadContinuationController.swift */, + 5EEA5B438944574494C5385F /* BackgroundSessionWakeCoordinator.swift */, + 42B6A047D0689D9E2E2D9749 /* PromptSurfaceArbiter.swift */, + F05D3B1199FC741AB2FDED67 /* PlaybackFailure.swift */, 8A9D0D2F2CD0E06F007A924D /* SingleFileDownloadService.swift */, ); path = Services; @@ -3233,6 +3405,14 @@ 0EA09FE5A35CEA0B87F83EF3 /* PreferencesSyncServiceTests.swift */, EC40E520F644AFA46DAD76B0 /* AudioMetadataServiceTests.swift */, A11C418B028982CF431C589F /* LibraryServiceReloadChaptersTests.swift */, + 5BE48B26D3726D5519F45ED9 /* IntegrationHostResolverTests.swift */, + FF75FEB4DEC37DBE232CD140 /* SyncQueueTests.swift */, + 04EDE7B20E949F916D8ECA2D /* UploadContinuationTests.swift */, + 60EBB5E9BC0C94F511BD4044 /* MissingItemsPassTests.swift */, + 83C482DCC8DC2DF811A3C7BC /* BackgroundSessionWakeTests.swift */, + 5C5F08C368941D45696C7891 /* MultipartUploadEngineTests.swift */, + C64E54AA0319D8F687DFB6E4 /* SyncPauseReportingTests.swift */, + B2339A98D9659EA78CB738CF /* TasksDataManagerStoreTests.swift */, ); path = Services; sourceTree = ""; @@ -3248,11 +3428,8 @@ 891E8E9FD0037C7E3C06C7B4 /* Connection */ = { isa = PBXGroup; children = ( - 7A8030B619D18CA088DAA40A /* IntegrationConnectionPayload.swift */, - 587EDC7DEA9D2985635F1161 /* IntegrationConnectionStore.swift */, 1FFF7B7B6E62B274D230C207 /* IntegrationServerAddress.swift */, ); - name = Connection; path = Connection; sourceTree = ""; }; @@ -3262,7 +3439,6 @@ 636E77872DF4948F00D4DC0A /* Details */, 636E777A2DF2907B00D4DC0A /* GridLayout */, 634FF7A02DF73DA0005D1C0D /* ListLayout */, - 8AF18A322CF0A26200238F8D /* JellyfinLibraryItem.swift */, 8A9D0D232CCED53C007A924D /* JellyfinLibraryViewModel.swift */, 8A9D0D252CCED543007A924D /* JellyfinLibraryView.swift */, 8A9D0D2B2CCFBEEE007A924D /* JellyfinLibraryItemImageView.swift */, @@ -3273,7 +3449,6 @@ 8AE4AAC32CCB9E3A00BAA927 /* Jellyfin */ = { isa = PBXGroup; children = ( - 636E777D2DF32FFC00D4DC0A /* Network */, 8A9D0D1A2CCCF36D007A924D /* Library Screen */, 8AE4AAC92CCBA1D200BAA927 /* Connection Screen */, 636E77882DF4C76600D4DC0A /* JellyfinRootView.swift */, @@ -3289,6 +3464,54 @@ path = "Connection Screen"; sourceTree = ""; }; + D95DF238A734AE606D62DADB /* ExternalProgress */ = { + isa = PBXGroup; + children = ( + 97ADE40DF29059A5B97E37D6 /* ExternalProgressProviding.swift */, + C85605CDB6C6075AADBE2D15 /* ExternalProgressService.swift */, + ); + path = ExternalProgress; + sourceTree = ""; + }; + 9911FBAA2F71CD1600DC9A2B /* SyncQueue */ = { + isa = PBXGroup; + children = ( + 9911FBBD2F74526F00DC9A2B /* SyncQueueProgressMonitor.swift */, + 25C877CDD6114F67F0CD2AFC /* QueueCounts.swift */, + 9911FBB72F72F46200DC9A2B /* QueuedSyncTask.swift */, + 099268746F86BD2193897F88 /* TaskPause.swift */, + 9911FBB12F71E54600DC9A2B /* SyncQueueRepository.swift */, + 9911FBAE2F71D03100DC9A2B /* SyncQueueService.swift */, + 9911FBAB2F71CD5F00DC9A2B /* AsyncOperation.swift */, + 9911FBB42F72486400DC9A2B /* ExternalUpdateProgressOperation.swift */, + 9911FBC32F76121C00DC9A2B /* FileUploadOperation.swift */, + 7CF4215FDCB8C2EA0F4A975D /* PartUploadTransport.swift */, + ); + path = SyncQueue; + sourceTree = ""; + }; + 992D6E112FA98AEF0091849F /* AudiobookShelf */ = { + isa = PBXGroup; + children = ( + 638487962EC7722400DF442B /* AudiobookShelfAudiobookDetailsData.swift */, + 638487B62EC7752000DF442B /* AudiobookShelfLibraryLevelData.swift */, + 6384879A2EC7722400DF442B /* AudiobookShelfLibraryItem.swift */, + 992D6E122FA98B410091849F /* Network */, + ); + path = AudiobookShelf; + sourceTree = ""; + }; + 992D6E122FA98B410091849F /* Network */ = { + isa = PBXGroup; + children = ( + 6384879C2EC7722400DF442B /* AudiobookShelfConnectionData.swift */, + 6384879D2EC7722400DF442B /* AudiobookShelfConnectionService.swift */, + 6384879F2EC7722400DF442B /* AudiobookShelfLibrary.swift */, + 75010F6AB433BBEEB7FA9376 /* AudiobookShelfOIDCFlow.swift */, + ); + path = Network; + sourceTree = ""; + }; 99329DB22F3A9B9C003F8E73 /* Views */ = { isa = PBXGroup; children = ( @@ -3332,6 +3555,41 @@ path = Models; sourceTree = ""; }; + 993BAADC2F9894D800FB8014 /* Recovered References */ = { + isa = PBXGroup; + children = ( + 6321324F2F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel */, + ); + name = "Recovered References"; + sourceTree = ""; + }; + 996D4CD02F98A41E00FC3B57 /* Integrations */ = { + isa = PBXGroup; + children = ( + B0D0FA9BA32EDF5F367C757B /* IntegrationError.swift */, + 7A823609A5B7EFC6D2D5D120 /* IntegrationLibraryItemProtocol.swift */, + 808E351A8B30ABCBA9EC00EF /* IntegrationDetailsViewModelProtocol.swift */, + 587EDC7DEA9D2985635F1161 /* IntegrationConnectionStore.swift */, + 7A8030B619D18CA088DAA40A /* IntegrationConnectionPayload.swift */, + 5265B16421BFC176BFFE4EA5 /* IntegrationHTTPClient.swift */, + 377C4C967D9AE1D471C2207C /* WebAuthenticating.swift */, + 22E85F55E03D1981F097661C /* PKCE.swift */, + 3451CA062919E03CEB975E92 /* IntegrationHostResolver.swift */, + A51357B087F7FB3CF6E08296 /* ExternalStreamResolver.swift */, + ); + path = Integrations; + sourceTree = ""; + }; + 99D9958B2F719ECF00BAF216 /* Jellyfin */ = { + isa = PBXGroup; + children = ( + 8AF18A322CF0A26200238F8D /* JellyfinLibraryItem.swift */, + 99D995942F71ABB700BAF216 /* JellyfinAudiobookDetailsData.swift */, + 636E777D2DF32FFC00D4DC0A /* Network */, + ); + path = Jellyfin; + sourceTree = ""; + }; 99F3C1F52F5F871F00AC91A2 /* Migration Policies */ = { isa = PBXGroup; children = ( @@ -3340,6 +3598,16 @@ path = "Migration Policies"; sourceTree = ""; }; + 99F3C21D2F69D09A00AC91A2 /* ExternalSource */ = { + isa = PBXGroup; + children = ( + 99F3C2202F69D32800AC91A2 /* ExternalImportViewModel.swift */, + AA1842BD89ED97A5DC8C0577 /* ExternalImportEvents.swift */, + 99F3C21E2F69D0AC00AC91A2 /* ExternalImportView.swift */, + ); + path = ExternalSource; + sourceTree = ""; + }; 9EDB4EF25480E070F2786662 /* MediaServerIntegration */ = { isa = PBXGroup; children = ( @@ -3348,14 +3616,11 @@ E55F466D61AE62A69DE4D371 /* BPNavigation.swift */, 3724BB21B1B88177C60BE4D8 /* IntegrationConnectionFormViewModelProtocol.swift */, 4D91D6855100BD1823B2674F /* IntegrationConnectionViewModelProtocol.swift */, - B0D0FA9BA32EDF5F367C757B /* IntegrationError.swift */, D5E6F7081A2B3C4D5E6F7080 /* MediaServersView.swift */, - 7A823609A5B7EFC6D2D5D120 /* IntegrationLibraryItemProtocol.swift */, 2E070421575334E1CD85C049 /* IntegrationLibraryViewModelProtocol.swift */, A4D9C8B6E5F1D2A7C4E9B81B /* IntegrationImageSizing.swift */, 7DDD933F7FDEDC1BF67EC567 /* TabEditingEnvironmentKey.swift */, 75AADE0F4BFEC0640FC687FA /* TagsFlowLayout.swift */, - 808E351A8B30ABCBA9EC00EF /* IntegrationDetailsViewModelProtocol.swift */, A68DC2D5A126AAFBFEC4F7EF /* Auth */, 891E8E9FD0037C7E3C06C7B4 /* Connection */, ); @@ -3428,7 +3693,6 @@ 63AD93D82E3C754B0077E73A /* ProfileListenedSectionView.swift */, 63AD93DC2E3C7F5A0077E73A /* ProfileProCalloutSectionView.swift */, 63AD93DE2E3C80B50077E73A /* ProfileSyncTasksSectionView.swift */, - 9FF710B62A212A19006490E0 /* QueuedSyncTasksView.swift */, 9FF710BC2A215686006490E0 /* QueuedSyncTaskType.swift */, 9FF710B82A213084006490E0 /* QueuedSyncTaskRowView.swift */, 63AD93D42E3C72830077E73A /* ProfileScreen.swift */, @@ -3439,6 +3703,7 @@ 9F4691EC2800C90400A8F0E8 /* Login */ = { isa = PBXGroup; children = ( + 9904420F2F8891F9007E2A44 /* MediaServersLoginView.swift */, 63C48D3D2E3E7F9E005FBB96 /* LoginViewModel.swift */, 9F2681B528898A7300359BD3 /* LoginDisclaimerView.swift */, 63C48C792E3DAC9D005FBB96 /* LoginView.swift */, @@ -3489,6 +3754,9 @@ 9F5F13662978D9B800F061A0 /* Views */ = { isa = PBXGroup; children = ( + 9911FBD62F7CACE200DC9A2B /* QueuedTasksView.swift */, + 4F26B24460975E0B0B15ABDA /* QueuedTaskDisplay.swift */, + FCC4219B7DA1CD232AE12888 /* SyncPauseReport.swift */, 9F5F13452977A06300F061A0 /* ProfileCardView.swift */, ); path = Views; @@ -3501,6 +3769,7 @@ 9F00A6232951F2F3005EA316 /* ItemDetailsViewModel.swift */, 9F00A5FB294F9B9A005EA316 /* ItemDetailsView.swift */, 63E1FF8E2E6C848200F2E003 /* ItemDetailsTitleSectionView.swift */, + F45535E22CB7445EB5DCAA5F /* ItemDetailsExternalResourceSectionView.swift */, ); path = ItemDetails; sourceTree = ""; @@ -3553,10 +3822,12 @@ children = ( 63C6C3172B5E0FE700FFE0D8 /* SyncTask.swift */, 9FBDBC782879409600D315A2 /* SyncableItem.swift */, + 9911FBC62F7738C100DC9A2B /* SyncableExternalResource.swift */, 9F9C7B5229F9672700E257B0 /* SyncableBookmark.swift */, 9FBDBC7F2879C6C900D315A2 /* UploadItemResponse.swift */, + 1713BB63A647FA8447545EFD /* MultipartUploadResponse.swift */, 9FBDBC7C287940D900D315A2 /* ContentsResponse.swift */, - 9F2E00912A568F800001FE20 /* IdentifiersResponse.swift */, + 9F2E00912A568F800001FE20 /* ItemsStatusResponse.swift */, 9F9C7B4F29F9670100E257B0 /* BookmarksResponse.swift */, 9F0872DA2A19867C00B7FD4A /* ArtworkResponse.swift */, 9FAFC9F32995BB5C00FD531E /* RemoteFileURLResponse.swift */, @@ -3610,9 +3881,7 @@ 99F3C2092F62285500AC91A2 /* ApiResponse.swift */, 9F9F3B6F280E4E4B004359DA /* SyncService.swift */, 9FDDD2DD289BEE590020C428 /* SyncJobScheduler.swift */, - 63C6C30B2B538B7A00FFE0D8 /* SyncTasksStorage.swift */, 63C6C30F2B54F14800FFE0D8 /* LibraryItemSyncOperation.swift */, - 638E64CD2B8E1CFD00DCFA3B /* SyncTasksCountService.swift */, ); path = Sync; sourceTree = ""; @@ -3620,11 +3889,7 @@ A68DC2D5A126AAFBFEC4F7EF /* Auth */ = { isa = PBXGroup; children = ( - 22E85F55E03D1981F097661C /* PKCE.swift */, - 5265B16421BFC176BFFE4EA5 /* IntegrationHTTPClient.swift */, - 377C4C967D9AE1D471C2207C /* WebAuthenticating.swift */, ); - name = Auth; path = Auth; sourceTree = ""; }; @@ -3690,6 +3955,7 @@ 635771D52F378EE800BB5F59 /* ThemedSection.swift */, 0FC8B923A5506F865253B97C /* AppServices.swift */, 4BB8BFBB9A63469069F0D44A /* WindowHelper.swift */, + F3B31C1E62A380C33C60FF9B /* PlaybackAlerts.swift */, ); path = Utils; sourceTree = ""; @@ -3782,6 +4048,7 @@ 63E784382E2EAF79001B2E94 /* Environment+BookPlayer.swift */, 63AE5A8C2E4C59FE002531B8 /* AppHostingViewController.swift */, 63D561342E5A214400615522 /* SimpleLibraryItem+SwiftUI.swift */, + 56AC45C580EED027394A645B /* MediaServerProvider+Icon.swift */, ); path = Extensions; sourceTree = ""; @@ -3902,8 +4169,10 @@ ); name = BookPlayerWatchKit; packageProductDependencies = ( - 62CADBA92725BCF000A4A98F /* Kingfisher */, 9F00F6E92807B5D300996BBB /* RevenueCat */, + 99D995992F71B05700BAF216 /* JellyfinAPI */, + 990775782FF33BE100E35FAE /* Get */, + 9907757D2FF33C1500E35FAE /* Kingfisher */, ); productName = BookPlayerWatchKit; productReference = 4140EA5E227289720009F794 /* BookPlayerWatchKit.framework */; @@ -3958,6 +4227,7 @@ 41F1A227254B0C6C0043FCF3 /* ZipArchive */, 8A495CC52CC85D27001B4244 /* JellyfinAPI */, 63AE44722E6161B60088668F /* DirectoryWatcher */, + 9907757F2FF33D5300E35FAE /* Get */, ); productName = "Audiobook Player"; productReference = 418B6CF81D2707F800F974FB /* BookPlayer.app */; @@ -3996,8 +4266,10 @@ ); name = BookPlayerKit; packageProductDependencies = ( - 62CADBA72725BCE800A4A98F /* Kingfisher */, 9F00F6E72807B5CB00996BBB /* RevenueCat */, + 99D995972F71B02D00BAF216 /* JellyfinAPI */, + 990775762FF33BD700E35FAE /* Get */, + 9907757B2FF33C0C00E35FAE /* Kingfisher */, ); productName = BookPlayerKit; productReference = 41A1B0D2226E9BC400EA0400 /* BookPlayerKit.framework */; @@ -4143,11 +4415,12 @@ 41F1A1F0254B09020043FCF3 /* XCRemoteSwiftPackageReference "IDZSwiftCommonCrypto" */, 41F1A202254B09C00043FCF3 /* XCRemoteSwiftPackageReference "Themeable" */, 41F1A20B254B0A0C0043FCF3 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, - 41F1A214254B0AA40043FCF3 /* XCRemoteSwiftPackageReference "Kingfisher" */, 41F1A226254B0C6C0043FCF3 /* XCRemoteSwiftPackageReference "ZipArchive" */, 9FE8188D2805BCE40089A41E /* XCRemoteSwiftPackageReference "purchases-ios" */, 8A495CC42CC8589F001B4244 /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, 63AE44712E6161B60088668F /* XCRemoteSwiftPackageReference "DirectoryWatcher" */, + 990775752FF33BD700E35FAE /* XCRemoteSwiftPackageReference "Get" */, + 9907757A2FF33C0C00E35FAE /* XCRemoteSwiftPackageReference "Kingfisher" */, ); productRefGroup = 418B6CF91D2707F800F974FB /* Products */; projectDirPath = ""; @@ -4189,6 +4462,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 99F3C2372F6D04AE00AC91A2 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4307,6 +4581,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 99F3C2362F6D04AE00AC91A2 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4388,7 +4663,6 @@ 9FA334B127C1499A0064E8EA /* ItemCellView.swift in Sources */, 4140EA43227288EF0009F794 /* ExtensionDelegate.swift in Sources */, 41A359C4276232E00020D5F5 /* MappingModel_v7_to_v8.xcmappingmodel in Sources */, - 632132522F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel in Sources */, 418CABB325EF28FC00D8C878 /* MappingModel_v3_to_v4.xcmappingmodel in Sources */, 9F82DF7127DF8203001B0EA8 /* WatchConnectivityService.swift in Sources */, 6350E4742CF4D2660077CDC1 /* PlayerToolbarView.swift in Sources */, @@ -4399,6 +4673,7 @@ 6350E46A2CF429760077CDC1 /* SleepTimer.swift in Sources */, 630EC82F2CEF9F0700C19411 /* ForcedEnvironment.swift in Sources */, 6350E4642CF004160077CDC1 /* LoadingView.swift in Sources */, + 996D4CCA2F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel in Sources */, 41D20DB125D5F5A100AAEE30 /* MappingModel_v1_to_v2.xcmappingmodel in Sources */, 9F82DF6D27DE985A001B0EA8 /* NowPlayingPlaybackControlsView.swift in Sources */, 6334CF1B2CF8D87500F1FA17 /* SkipDurationListView.swift in Sources */, @@ -4425,6 +4700,7 @@ 6399D0702CEBA35D00A2E278 /* RemoteItemListView.swift in Sources */, 41C3396225E04103003ED2B0 /* MappingModel_v2_to_v3.xcmappingmodel in Sources */, 6350E4722CF4D0220077CDC1 /* PlayerControlsView.swift in Sources */, + 63673F97304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel in Sources */, 9FA334C727C28D650064E8EA /* PlaybackControlsView.swift in Sources */, 4140EA8522728A160009F794 /* BookPlayer.xcdatamodeld in Sources */, 63CD851D2CE2964900EDBEA8 /* SettingsView.swift in Sources */, @@ -4439,13 +4715,17 @@ 99F3C20B2F62285800AC91A2 /* ApiResponse.swift in Sources */, 4140EA6C2272898F0009F794 /* DataManager.swift in Sources */, 4140EA71227289A20009F794 /* UIColor+BookPlayer.swift in Sources */, + 99C919D02F99A8C100F66081 /* IntegrationDetailsViewModelProtocol.swift in Sources */, 9F0872DC2A19867C00B7FD4A /* ArtworkResponse.swift in Sources */, 9FF383D42A40F97000BBAC11 /* MappingModel_v8_to_v9.xcmappingmodel in Sources */, 3F66408B2E162ABF00356522 /* AudioMetadataService.swift in Sources */, 4138CE1726E584B60014F11E /* BookmarkType.swift in Sources */, 9F9C7B5429F9672700E257B0 /* SyncableBookmark.swift in Sources */, 5126F123258E9F18009965DC /* URL+BookPlayer.swift in Sources */, + 9911FBAC2F71CD6300DC9A2B /* AsyncOperation.swift in Sources */, 9FFCC090289418CA00F4952E /* SimpleChapter.swift in Sources */, + 9911FBC52F76122400DC9A2B /* FileUploadOperation.swift in Sources */, + CC6BB739AD7C3CAE40BB940C /* PartUploadTransport.swift in Sources */, 6399EA392F12BB690077BB13 /* PasskeyModels.swift in Sources */, 419BD5992443FD04001E50A0 /* Intents.intentdefinition in Sources */, 62AAE22D274AA6DE001EB9FF /* LibraryService.swift in Sources */, @@ -4459,11 +4739,13 @@ 63CD851F2CE2EEE200EDBEA8 /* Fonts.swift in Sources */, 99F3C1FB2F5F873900AC91A2 /* UUIDMigrationPolicy.swift in Sources */, 9961DA72E9D53B8DA131BEB2 /* BPFont.swift in Sources */, + 9911FBA92F71BD3000DC9A2B /* JellyfinConnectionService.swift in Sources */, + 99F3C2352F6BBECC00AC91A2 /* JellyfinConnectionData.swift in Sources */, 41A90C4927564DAA00C30394 /* BookPlayerError.swift in Sources */, 3FF5BA6A2E10EA8000B82BCC /* HardcoverBook+CoreDataClass.swift in Sources */, 63344C022EA7097D00B90DF7 /* DatabaseBackupService.swift in Sources */, 41C23402272E1960006BC7B8 /* SimpleTheme.swift in Sources */, - 638E64CF2B8E1CFD00DCFA3B /* SyncTasksCountService.swift in Sources */, + 992D6E1C2FA98CBE0091849F /* AudiobookShelfLibrary.swift in Sources */, 63125D132C36D97400D35533 /* EventsService.swift in Sources */, 3F6640902E172DF500356522 /* MappingModel_v9_to_v10.xcmappingmodel in Sources */, 3FF5BA672E10EA6F00B82BCC /* HardcoverBook+CoreDataProperties.swift in Sources */, @@ -4476,6 +4758,7 @@ 4140EA76227289B50009F794 /* Chapter+CoreDataClass.swift in Sources */, 41A359C6276232E00020D5F5 /* MappingModel_v7_to_v8.xcmappingmodel in Sources */, 63CD851E2CE2EEBC00EDBEA8 /* UIFont+BookPlayer.swift in Sources */, + 99F3C2142F65174700AC91A2 /* SimpleExternalResource.swift in Sources */, 62CADBAE2725FE2900A4A98F /* AVAudioAssetImageDataProvider.swift in Sources */, 412AB71627017ABE00969618 /* DBVersion.swift in Sources */, 9FC1E45A2814E0B000522FA8 /* NetworkUtils.swift in Sources */, @@ -4487,6 +4770,7 @@ 6356F9CD2AC8A1CE00B7A027 /* DatabaseInitializer.swift in Sources */, 41C23400272E195B006BC7B8 /* SimpleItemType.swift in Sources */, 4140EA6D227289930009F794 /* Constants.swift in Sources */, + 99F3C2412F6E322800AC91A2 /* SchemaV3SyncTasksModels.swift in Sources */, 41EB071C2752FA6B00EFEE13 /* PlaybackService.swift in Sources */, 9FDDD2D9289B64440020C428 /* SyncStatus.swift in Sources */, 9F9C7B5129F9670100E257B0 /* BookmarksResponse.swift in Sources */, @@ -4495,9 +4779,11 @@ 63E1FF912E6C8C2000F2E003 /* SyncJobType.swift in Sources */, 9FC1E45D2814E6A500522FA8 /* NetworkProvider.swift in Sources */, 9FC1E4602814F66F00522FA8 /* AccountAPI.swift in Sources */, + 9911FBBF2F74527800DC9A2B /* SyncQueueProgressMonitor.swift in Sources */, + 7AA06DC64EDBA7F10753CF4C /* QueueCounts.swift in Sources */, 9FC1E4792816001400522FA8 /* KeychainService.swift in Sources */, + 99D995902F71AB6800BAF216 /* JellyfinLibraryItem.swift in Sources */, 4140EA7F227289D20009F794 /* Library+CoreDataProperties.swift in Sources */, - 63C6C30E2B538D8600FFE0D8 /* SyncTasksStorage.swift in Sources */, 4140EA7A227289C20009F794 /* Book+CoreDataClass.swift in Sources */, 9F49072D2903663800054AD6 /* SortType.swift in Sources */, 6357F11A2A8BA084007947FC /* BPURLSession.swift in Sources */, @@ -4505,16 +4791,22 @@ 9FBDBC7E287940D900D315A2 /* ContentsResponse.swift in Sources */, 4140EA7C227289C70009F794 /* LibraryItem+CoreDataClass.swift in Sources */, 63C6C3122B54F16800FFE0D8 /* LibraryItemSyncOperation.swift in Sources */, + 9911FBAF2F71D03800DC9A2B /* SyncQueueService.swift in Sources */, + 992D6E1A2FA98C8F0091849F /* AudiobookShelfLibraryLevelData.swift in Sources */, 638352CE2E67F327009EE79E /* TasksDataManager.swift in Sources */, 638352D02E67F327009EE79E /* SyncTasksModels.swift in Sources */, 638352D12E67F327009EE79E /* SwiftDataModels+Extensions.swift in Sources */, + 9911FBB82F72F46900DC9A2B /* QueuedSyncTask.swift in Sources */, + A6E159ACC6815095CB182A05 /* TaskPause.swift in Sources */, 4140EA74227289AE0009F794 /* Theme+CoreDataClass.swift in Sources */, 4140EA6E227289960009F794 /* DeleteMode.swift in Sources */, 9F6BC86727E6D03E002CF2A6 /* WatchApplicationContext.swift in Sources */, 9FAFC9F52995BB5C00FD531E /* RemoteFileURLResponse.swift in Sources */, 4140EA77227289B90009F794 /* Chapter+CoreDataProperties.swift in Sources */, 4140EA7B227289C40009F794 /* Book+CoreDataProperties.swift in Sources */, + 992D6E182FA98C5C0091849F /* AudiobookShelfLibraryItem.swift in Sources */, 4140EA81227289D80009F794 /* Folder+CoreDataProperties.swift in Sources */, + 992D6E1E2FA98E1A0091849F /* AudiobookShelfAudiobookDetailsData.swift in Sources */, 41EB07192752F1BA00EFEE13 /* PlayableChapter.swift in Sources */, 9FC1E44B2814C4DE00522FA8 /* NetworkClient.swift in Sources */, 3FA43BF92E110B4900DF9B1F /* SimpleHardcoverBook.swift in Sources */, @@ -4526,35 +4818,58 @@ 9F56C84E287B734C00EA9751 /* BPLogger.swift in Sources */, 6327E0C72ADB9914004780DC /* DownloadState.swift in Sources */, 418CABB525EF28FC00D8C878 /* MappingModel_v3_to_v4.xcmappingmodel in Sources */, + 9911FBC72F7738CC00DC9A2B /* SyncableExternalResource.swift in Sources */, 9F9F3B71280E4E4B004359DA /* SyncService.swift in Sources */, + 996D4CC62F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel in Sources */, + 992D6E152FA98B4D0091849F /* AudiobookShelfConnectionService.swift in Sources */, + 992D6E162FA98B4D0091849F /* AudiobookShelfConnectionData.swift in Sources */, + 996D4CD42F98A50C00FC3B57 /* IntegrationLibraryItemProtocol.swift in Sources */, 4124AB2225DFE1A60007C839 /* CoreDataStack.swift in Sources */, 9F7B64722803216100895ECC /* Account+CoreDataProperties.swift in Sources */, + 9911FBB22F71E54C00DC9A2B /* SyncQueueRepository.swift in Sources */, 9F7B64752803217400895ECC /* Account+CoreDataClass.swift in Sources */, 9FBF33ED29F6293C00501639 /* SimpleBookmark.swift in Sources */, 639AC98B2AD9F2840053AFC6 /* BPTaskDownloadDelegate.swift in Sources */, 4140EA75227289B20009F794 /* Theme+CoreDataProperties.swift in Sources */, 99F3C1FF2F5FC2F200AC91A2 /* SchemaV1SyncTasksModels.swift in Sources */, 9FBDBC812879C6C900D315A2 /* UploadItemResponse.swift in Sources */, + 04376D0C9384C1655823B498 /* MultipartUploadResponse.swift in Sources */, 41A90C4627563F5A00C30394 /* ItemType.swift in Sources */, - 9F2E00932A568F800001FE20 /* IdentifiersResponse.swift in Sources */, + 9F2E00932A568F800001FE20 /* ItemsStatusResponse.swift in Sources */, 4140EA7E227289CE0009F794 /* Library+CoreDataClass.swift in Sources */, + 9911FBB62F72486D00DC9A2B /* ExternalUpdateProgressOperation.swift in Sources */, 41C233FE272E1958006BC7B8 /* SimpleLibraryItem.swift in Sources */, + 996D4CD22F98A45300FC3B57 /* IntegrationError.swift in Sources */, 416AAC3523F515B0005AD04F /* String+BookPlayer.swift in Sources */, + 99D995952F71ABBD00BAF216 /* JellyfinAudiobookDetailsData.swift in Sources */, 6308260B2AF5B66D002ACE0D /* UserDefaults+BookPlayer.swift in Sources */, FEEDBABE0000000000000002 /* SharedWidgetStore.swift in Sources */, 63398E3D2D68254000A6934E /* SimplePlaybackRecord.swift in Sources */, 99F3C2042F5FC76D00AC91A2 /* MigrationPlan.swift in Sources */, 41A8942B2652A7DF0032E972 /* Bundle+BookPlayer.swift in Sources */, 4138CE1226E5542D0014F11E /* Bookmark+CoreDataClass.swift in Sources */, + 99F3C2112F650F1200AC91A2 /* ExternalResource+CoreDataProperties.swift in Sources */, 41EB07162752F17B00EFEE13 /* PlayableItem.swift in Sources */, + 99D9958A2F719E7E00BAF216 /* (null) in Sources */, 4140EA84227289E60009F794 /* BookPlayer.xcdatamodeld in Sources */, - 632132552F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel in Sources */, + 99F3C20E2F650F0900AC91A2 /* ExternalResource+CoreDataClass.swift in Sources */, 99F3C2072F5FD41300AC91A2 /* SchemaV2SyncTasksModels.swift in Sources */, 412AB70A2701421600969618 /* ManualOrderMigrationUtils.swift in Sources */, 63B9149E2EE47951000918D8 /* AppEnvironment.swift in Sources */, F9FA95F9A4AE359006A85B88 /* PreferencesAPI.swift in Sources */, 2D7D5973F598D517CBCCE0ED /* PreferencesSyncService.swift in Sources */, CCC7E1CC127610C3687B8A50 /* SortPreferencesResolving.swift in Sources */, + 89058F6081ECE1258DE9607D /* AudiobookShelfOIDCFlow.swift in Sources */, + C972F5D6BD6375832F59BF7E /* IntegrationHTTPClient.swift in Sources */, + 82BB1A19497560FC62C4741C /* WebAuthenticating.swift in Sources */, + AE284AB4D4139ED869288F99 /* IntegrationConnectionPayload.swift in Sources */, + 63673F99304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel in Sources */, + EF5A25679D73F58A5D551D01 /* IntegrationConnectionStore.swift in Sources */, + 8AC2579BAAAAFE0091DA9285 /* PKCE.swift in Sources */, + 45FE3490229249F95A3CF5FF /* IntegrationHostResolver.swift in Sources */, + 4DCB4D54E03F9CDA56865A89 /* ExternalStreamResolver.swift in Sources */, + F2083D16D6C22A37C49AC2B7 /* ExternalProgressProviding.swift in Sources */, + ED7E919E39E0454432171664 /* ExternalProgressService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4566,6 +4881,7 @@ 4106414025872614008EB8D0 /* BarView.swift in Sources */, 3F66408F2E172DF500356522 /* MappingModel_v9_to_v10.xcmappingmodel in Sources */, 63E893B02CB0AA7100946CD4 /* LastBookStartPlaybackIntent.swift in Sources */, + 996D4CC92F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel in Sources */, 630826062AF525F1002ACE0D /* SharedWidgetContainerView.swift in Sources */, 4106414925872699008EB8D0 /* TimeListenedSmallView.swift in Sources */, 417D996F256D73B400C3B753 /* Intents.intentdefinition in Sources */, @@ -4587,6 +4903,7 @@ 416A29DA256AB6A900605395 /* BookPlayer.xcdatamodeld in Sources */, 630826112AF6CA44002ACE0D /* SharedIconWidget.swift in Sources */, 637DAB0B2AEB3F6D006DC2D1 /* WidgetEntries.swift in Sources */, + 63673F9A304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel in Sources */, 418CABB625EF28FC00D8C878 /* MappingModel_v3_to_v4.xcmappingmodel in Sources */, 63E893932CAFA89000946CD4 /* BPPlayerError.swift in Sources */, 41ADD6DA2570AC6300660C64 /* RecentBooksWidgetView.swift in Sources */, @@ -4598,7 +4915,6 @@ 63E893B32CB0AACE00946CD4 /* PlayLastControlWidgetView.swift in Sources */, 639720722CAAF8290045A4DB /* LastPlayedProvider.swift in Sources */, 630826022AF295AE002ACE0D /* CornerView.swift in Sources */, - 632132532F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4615,7 +4931,6 @@ 0EF52614EF6770D09CA81CE4 /* IntegrationConnectedView.swift in Sources */, B26B34D894351E8452FBB64B /* IntegrationServerInformationSectionView.swift in Sources */, 634BA58D2C14D5330015314D /* SecondOnboardingType.swift in Sources */, - 8AF18A332CF0A26200238F8D /* JellyfinLibraryItem.swift in Sources */, 9F82DF9C27DFE46B001B0EA8 /* PhoneWatchConnectivityService.swift in Sources */, 9F00A600295001C0005EA316 /* ItemDetailsArtworkSectionView.swift in Sources */, 8A9D0D262CCED543007A924D /* JellyfinLibraryView.swift in Sources */, @@ -4630,6 +4945,7 @@ 63E893982CAFAC7500946CD4 /* PlayerManagerProtocol.swift in Sources */, 631908B82E3718F8009249C1 /* AppIconView.swift in Sources */, 63E1FF8F2E6C848200F2E003 /* ItemDetailsTitleSectionView.swift in Sources */, + 234660AA5CF5435784D5AB09 /* ItemDetailsExternalResourceSectionView.swift in Sources */, 632119D72E543973005B3E10 /* ItemListViewModel.swift in Sources */, 4151A6B326E491A800E49DBE /* NibLoadableView.swift in Sources */, 63F8BDE92E421C3B0088FB66 /* CircularProgressView.swift in Sources */, @@ -4676,7 +4992,6 @@ 63C48D522E50AAA1005FBB96 /* AccountPasskeySectionView.swift in Sources */, 4165EE0520A743D500616EDF /* BookPlayer.xcdatamodeld in Sources */, 63C48D3C2E3E7D29005FBB96 /* SettingsCompleteAccountView.swift in Sources */, - 9FF710B72A212A19006490E0 /* QueuedSyncTasksView.swift in Sources */, 63C1A8B02B0915EE00C4B418 /* WidgetUtils.swift in Sources */, 41B2A5ED21CCC6D100917584 /* AppNavigationController.swift in Sources */, 9F22DE40288D8BC800056FCD /* BaseLabel.swift in Sources */, @@ -4712,6 +5027,7 @@ 63B4A3C22E92A87C00784A22 /* BookmarksFileTransferable.swift in Sources */, 6356F9B52AC7CC5600B7A027 /* CancelSleepTimerIntent.swift in Sources */, 6319089B2E335196009249C1 /* ContributorsListView.swift in Sources */, + 63673F96304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel in Sources */, 63AD93DF2E3C80B50077E73A /* ProfileSyncTasksSectionView.swift in Sources */, 6356F9C12AC823EE00B7A027 /* LastBookStartPlaybackIntent.swift in Sources */, C3A479152094CA3800D92122 /* UIImage+BookPlayer.swift in Sources */, @@ -4744,16 +5060,22 @@ 63E893922CAFA89000946CD4 /* BPPlayerError.swift in Sources */, 63682CBE2E2F422900C15FC1 /* DebugFileTransferable.swift in Sources */, 4160A0A123F304530039166B /* LocalizableLabel.swift in Sources */, + 9911FBD72F7CACEC00DC9A2B /* QueuedTasksView.swift in Sources */, + 32F59104BFC8DDAA7DBEC934 /* QueuedTaskDisplay.swift in Sources */, + 02C5C304687E934E42ABF54C /* SyncPauseReport.swift in Sources */, 4137BBCC272DAF2E009ED9FE /* MVVMControllerProtocol.swift in Sources */, 631B360C2ABE8ACC001F4C1C /* BPModalOnlyPresentationFlow.swift in Sources */, 99B367FC2F5108BF00C38F7F /* MediaActionRow.swift in Sources */, D6BA8F162A4CA94800C2BD9A /* StorageRowView.swift in Sources */, 9F3C436A284181690066D99A /* DataInitializerCoordinator.swift in Sources */, 9F3C436B284181C70066D99A /* AlertPresenter.swift in Sources */, + 990442102F889201007E2A44 /* MediaServersLoginView.swift in Sources */, 63E893B22CB0AACE00946CD4 /* PlayLastControlWidgetView.swift in Sources */, 63B760FC2C33B77F00AA98C7 /* SupportProfileView.swift in Sources */, 638487CD2EC77B1200DF442B /* AudiobookShelfAudiobookDetailsView.swift in Sources */, 638487CE2EC77B1200DF442B /* AudiobookShelfAudiobookDetailsViewModel.swift in Sources */, + 99F3C2212F69D32E00AC91A2 /* ExternalImportViewModel.swift in Sources */, + 1A304754D41FB1E66FDD5EAF /* ExternalImportEvents.swift in Sources */, 63344C002EA708F800B90DF7 /* BackupDatabaseOperation.swift in Sources */, 9F00A6212950F44B005EA316 /* ImagePicker.swift in Sources */, 638487BB2EC7764800DF442B /* AudiobookShelfLibraryListItemView.swift in Sources */, @@ -4785,11 +5107,7 @@ 99329DC12F3AAB57003F8E73 /* DismissableRegionView.swift in Sources */, 639DF4982E634C8600B29BBB /* MiniPlayerArtworkView.swift in Sources */, 9FB20EB729A423410021663B /* InterfaceUpdater.swift in Sources */, - 638487A22EC7722400DF442B /* AudiobookShelfLibrary.swift in Sources */, - 638487A32EC7722400DF442B /* AudiobookShelfLibraryItem.swift in Sources */, - 638487A42EC7722400DF442B /* AudiobookShelfConnectionService.swift in Sources */, - 638487A52EC7722400DF442B /* AudiobookShelfConnectionData.swift in Sources */, - 638487A72EC7722400DF442B /* AudiobookShelfAudiobookDetailsData.swift in Sources */, + 99F3C21F2F69D0AF00AC91A2 /* ExternalImportView.swift in Sources */, 3F7B64362E0F71E900299D97 /* HardcoverSettingsView.swift in Sources */, 631908A12E33EF50009249C1 /* ConfettiView.swift in Sources */, 6399EA352F12BA5B0077BB13 /* PasskeyService.swift in Sources */, @@ -4809,15 +5127,12 @@ 6338C7912E4AA32E008016E5 /* FolderView.swift in Sources */, 99329DD32F3D7493003F8E73 /* BPAlertModifier.swift in Sources */, 63B760F72C32734000AA98C7 /* SecondOnboardingResponse.swift in Sources */, - 99F3C1FC2F5F873900AC91A2 /* UUIDMigrationPolicy.swift in Sources */, 41640A3724416EE8004FB97B /* Intents.intentdefinition in Sources */, 63AD93DB2E3C7C020077E73A /* PlayerState.swift in Sources */, 6370427F2E2BEAC7005353D1 /* SettingsView.swift in Sources */, 41AD3DA1221C7CAB00DC41E1 /* Icon.swift in Sources */, 631908972E33442A009249C1 /* SettingsTipJarView.swift in Sources */, C39401E920DEE83200F3DC71 /* UIView+BookPlayer.swift in Sources */, - 8AAD8A552CEE884F000A4B4B /* JellyfinConnectionService.swift in Sources */, - 632132542F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel in Sources */, 637043542E2DED05005353D1 /* SettingsMailView.swift in Sources */, 8A2A22392CEFEB8E00E73A2D /* AdaptiveVGrid.swift in Sources */, 631908B02E369FFA009249C1 /* AutoSleepTimerSectionView.swift in Sources */, @@ -4836,9 +5151,9 @@ 9FD8D95829DC53750074C2D8 /* CoreServices.swift in Sources */, 5163037679ED3EE0D1866A8F /* AppServices.swift in Sources */, F50163BEFB92FB6B1B778C5E /* WindowHelper.swift in Sources */, + C0C40794A90EEED394B4C957 /* PlaybackAlerts.swift in Sources */, 63E54C342D50576C0040355D /* TipJarView.swift in Sources */, 63AD93DD2E3C7F5A0077E73A /* ProfileProCalloutSectionView.swift in Sources */, - 638487B72EC7752000DF442B /* AudiobookShelfLibraryLevelData.swift in Sources */, 631908B62E36B007009249C1 /* SettingsAppIconsView.swift in Sources */, 63AD93D92E3C754B0077E73A /* ProfileListenedSectionView.swift in Sources */, 631686102E372FE7004A322F /* ThemesView.swift in Sources */, @@ -4851,6 +5166,7 @@ 8A9D0D2A2CCFBB96007A924D /* JellyfinLibraryGridItemView.swift in Sources */, 6338C78F2E4AA327008016E5 /* BookView.swift in Sources */, 41A1B12D226FC7E500EA0400 /* ImportManager.swift in Sources */, + 6D464C6A588947878DCF1DB4 /* ImportConfirmationView.swift in Sources */, 6370434C2E2C046F005353D1 /* SettingsPrivacySectionView.swift in Sources */, 8A9D0D242CCED53C007A924D /* JellyfinLibraryViewModel.swift in Sources */, 631908AA2E369BDB009249C1 /* ProgressSeekingSectionView.swift in Sources */, @@ -4872,6 +5188,7 @@ 631908A62E369572009249C1 /* ProgressLabelsSectionView.swift in Sources */, 3FF8EA502E11E2110054CD2C /* ItemDetailsHardcoverSectionViewModel.swift in Sources */, 3F7B64452E0FB29600299D97 /* BooksData.swift in Sources */, + A179BB26325B4699A9EA15CA /* BookByIdData.swift in Sources */, 6356F9B92AC7CDDE00B7A027 /* EndChapterSleepTimerIntent.swift in Sources */, 63FB67882E714F0A00331767 /* ButtonFreeView.swift in Sources */, 62F2F25F25E18C7500E1D6A0 /* ImportableItem.swift in Sources */, @@ -4879,13 +5196,13 @@ 9FE86B8E2A544CB200B5450E /* Binding+BookPlayer.swift in Sources */, 41A1B12E226FC7E500EA0400 /* ImportOperation.swift in Sources */, 6356D48C2C584EFD00994B71 /* CustomSkipForwardIntent.swift in Sources */, - 5126F121258E9F18009965DC /* URL+BookPlayer.swift in Sources */, 6370433B2E2BF77C005353D1 /* SettingsScreen.swift in Sources */, 9F5F13462977A06300F061A0 /* ProfileCardView.swift in Sources */, 9F2DA27F27F0C68D00C8EF2B /* CarPlaySceneDelegate.swift in Sources */, 4193E202243A91AE004D6A82 /* ActionParserService.swift in Sources */, 41A359C3276232E00020D5F5 /* MappingModel_v7_to_v8.xcmappingmodel in Sources */, 63B760F92C32738E00AA98C7 /* StoryAccountSubscriptionService.swift in Sources */, + 99D995932F71ABAA00BAF216 /* JellyfinAudiobookDetailsView.swift in Sources */, 9F5F12DB2976E8CD00F061A0 /* ProfileView.swift in Sources */, 41C8ABD126836F50003B67D1 /* ImportViewModel.swift in Sources */, 41D20DAF25D5F5A100AAEE30 /* MappingModel_v1_to_v2.xcmappingmodel in Sources */, @@ -4908,10 +5225,14 @@ 9F89D89C27EDFCA400F73947 /* SceneDelegate.swift in Sources */, 638761D32E62BD7A009332E8 /* ItemListSelectionView.swift in Sources */, 3F6640912E172DF500356522 /* MappingModel_v9_to_v10.xcmappingmodel in Sources */, - 8ADD46242CF38F9F002E9C50 /* JellyfinAudiobookDetailsView.swift in Sources */, 99329DB72F3AA8F6003F8E73 /* PlayControlsRowView.swift in Sources */, 416AAC3323F51031005AD04F /* LocalizableButton.swift in Sources */, 63B50F052B692E4200BCABBA /* ListSyncRefreshService.swift in Sources */, + F015116186D7F12201CDB4C0 /* SyncPauseReporter.swift in Sources */, + 4907B9D7B059A2A93D35AAE0 /* UploadContinuationController.swift in Sources */, + AD7114A34343C516BD1890E2 /* BackgroundSessionWakeCoordinator.swift in Sources */, + D071ABD44684949C58C7D8D0 /* PromptSurfaceArbiter.swift in Sources */, + 5823F9DE917AE83D3C4DC04E /* PlaybackFailure.swift in Sources */, 416A297D2568671F00605395 /* AVPlayer+BookPlayer.swift in Sources */, 634BA5A52C176B5A0015314D /* StoryActionView.swift in Sources */, 8A9D0D302CD0E074007A924D /* SingleFileDownloadService.swift in Sources */, @@ -4939,14 +5260,11 @@ 63C48D512E3F1FCC005FBB96 /* ItemListView.swift in Sources */, 412AB70E2701463100969618 /* LoadingViewModel.swift in Sources */, 637043402E2BFD6C005353D1 /* SettingsStorageSectionView.swift in Sources */, - 8AAD8A572CEE88E5000A4B4B /* JellyfinConnectionData.swift in Sources */, 631908AC2E369E31009249C1 /* GlobalSpeedSectionView.swift in Sources */, F906EF4FC85B1CCE138B230D /* PasskeyEmailInputView.swift in Sources */, 07416E5AD384927D90BFB6EE /* PasskeyCreatingView.swift in Sources */, CA3B408256F8458669106CF9 /* IntegrationConnectionViewModelProtocol.swift in Sources */, 7CAD4B67352939D0A1E54A21 /* IntegrationConnectionFormViewModelProtocol.swift in Sources */, - C3C998E7EA2919BB438B337C /* IntegrationLibraryItemProtocol.swift in Sources */, - 760180C62F243705DE6B3224 /* IntegrationError.swift in Sources */, D5E6F7081A2B3C4D5E6F7081 /* MediaServersView.swift in Sources */, 18D0AD99D1AAA10976F75C11 /* BPNavigation.swift in Sources */, 9236C57F833C70652BDD1FA3 /* TabEditingEnvironmentKey.swift in Sources */, @@ -4957,18 +5275,15 @@ 9586CECD8FB418C6CFB0A7DD /* IntegrationLibraryView.swift in Sources */, 41A6E5ED3159D85692581CC9 /* IntegrationLibraryGridView.swift in Sources */, C53864BEFAE4D1CEC668A6B3 /* IntegrationLibraryListView.swift in Sources */, - F7CB281CF765C468BFA9B0D8 /* IntegrationDetailsViewModelProtocol.swift in Sources */, + 996D4CC72F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel in Sources */, A7FA45CB2BD6567C9B5EA372 /* IntegrationTagsView.swift in Sources */, A34DA648F3EF87A9E11631B3 /* IntegrationAudiobookDetailsView.swift in Sources */, 78D8C50324731BBDF9E9281E /* LibraryOptionsView.swift in Sources */, - 81173188169F2573B4F5AA90 /* AudiobookShelfOIDCFlow.swift in Sources */, - 4E6F3810544774F70335675E /* PKCE.swift in Sources */, - 3FDACF42BAD03DBFA7AD918B /* IntegrationHTTPClient.swift in Sources */, - 0199296D1E63EE13FC3558E6 /* WebAuthenticating.swift in Sources */, - 0235B7EE8FA51C49AFBBBE71 /* IntegrationConnectionPayload.swift in Sources */, - 843575DDF1CEEBC9A2EF015C /* IntegrationConnectionStore.swift in Sources */, 6DF751D96098B1A3224FDF43 /* IntegrationServerAddress.swift in Sources */, 39313742614BCCA918DA7C0E /* IntegrationConnectionFlowView.swift in Sources */, + BF96AEBA456420ADE446427F /* URL+BookPlayer.swift in Sources */, + ED1900FB7892C8FA42A5A665 /* UUIDMigrationPolicy.swift in Sources */, + 048E8DAA13C4530F4419A030 /* MediaServerProvider+Icon.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5005,6 +5320,11 @@ 9F8A9A5E27AC3F8C0093AA1C /* PlayableItemTests.swift in Sources */, 232F1DA50BB33EB5A629B8B7 /* PlayableChapterTests.swift in Sources */, 4163E313214AC43000072AA2 /* ImportOperationTests.swift in Sources */, + 9AB6B44F84D195FCCF7A4F9F /* PromptSurfaceArbiterTests.swift in Sources */, + 28EEB2588DE468A6B8C15272 /* RefreshTaskOperationTests.swift in Sources */, + 3EBBB264D481A0F2DF48763E /* ListSyncRefreshServiceTests.swift in Sources */, + FDA22898B1CD320A1D8F6C52 /* QueuedTaskDisplayTests.swift in Sources */, + 74054210C96493349539697D /* NetworkClientQueryEncodingTests.swift in Sources */, 0CEC6B48350FC9C0A0B7D1C0 /* SyncTasksStorageTests.swift in Sources */, 1A26F7D72FFD6D625A313FC5 /* AudioMetadataServiceTests.swift in Sources */, 466E4B7066F10717C8CE7B3E /* LibraryServiceReloadChaptersTests.swift in Sources */, @@ -5017,6 +5337,14 @@ ADBA6DD206F76CF92C0765A3 /* WebAuthenticatingTests.swift in Sources */, E101E3603ACF4DB50AA0037B /* JellyfinQuickConnectTests.swift in Sources */, 5A9506FC8DAB858289E0EF89 /* IntegrationServerAddressTests.swift in Sources */, + 3F058A74415A3AF6C17DA8F4 /* IntegrationHostResolverTests.swift in Sources */, + CB3844DD8C695EAD32A4A811 /* SyncQueueTests.swift in Sources */, + 6C5741E68D44175ECF900294 /* UploadContinuationTests.swift in Sources */, + AD8D09AF57F6C7799943EA1F /* MissingItemsPassTests.swift in Sources */, + 50EEEAE96509B6867FC9180B /* BackgroundSessionWakeTests.swift in Sources */, + B2B1B1BF822F47AA40EE1B71 /* MultipartUploadEngineTests.swift in Sources */, + 3F42C29BC62913D44C22CBC0 /* SyncPauseReportingTests.swift in Sources */, + 786041B335FF61901E55666C /* TasksDataManagerStoreTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5024,8 +5352,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 996D4CC52F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel in Sources */, + 9911FBBE2F74527800DC9A2B /* SyncQueueProgressMonitor.swift in Sources */, + F99B9D63217ABDAFED95696A /* QueueCounts.swift in Sources */, 9FC1E46C2815A8CA00522FA8 /* LibraryAPI.swift in Sources */, 9FAFC9F42995BB5C00FD531E /* RemoteFileURLResponse.swift in Sources */, + 9911FBB92F72F46900DC9A2B /* QueuedSyncTask.swift in Sources */, + 693CFE4C25CFFA22F7151780 /* TaskPause.swift in Sources */, + 992D6E172FA98C5C0091849F /* AudiobookShelfLibraryItem.swift in Sources */, 4124AB2125DFE1A60007C839 /* CoreDataStack.swift in Sources */, 41A1B11F226F88C500EA0400 /* Chapter+CoreDataClass.swift in Sources */, 4138CE1626E584B60014F11E /* BookmarkType.swift in Sources */, @@ -5035,15 +5369,19 @@ 9F1345B82938DF360089B1DE /* UIFont+BookPlayer.swift in Sources */, 639AC98C2AD9F2840053AFC6 /* BPTaskDownloadDelegate.swift in Sources */, 9FFCC08F289418CA00F4952E /* SimpleChapter.swift in Sources */, + 9911FBC42F76122400DC9A2B /* FileUploadOperation.swift in Sources */, + 21B642BBCC9CEC43E00412CD /* PartUploadTransport.swift in Sources */, 6399EA3A2F12BB690077BB13 /* PasskeyModels.swift in Sources */, 416AAC3423F515AF005AD04F /* String+BookPlayer.swift in Sources */, 9FBDBC792879409600D315A2 /* SyncableItem.swift in Sources */, + 996D4CD32F98A50C00FC3B57 /* IntegrationLibraryItemProtocol.swift in Sources */, 62AAE22E274AA6DE001EB9FF /* LibraryService.swift in Sources */, 9F2DC9E52A014BB5006CDF1F /* PricingModel.swift in Sources */, 41A1B126226F88C500EA0400 /* LibraryItem+CoreDataProperties.swift in Sources */, 6327E0C62ADB9913004780DC /* DownloadState.swift in Sources */, 63B9149F2EE47951000918D8 /* AppEnvironment.swift in Sources */, 63C6C3192B5E102200FFE0D8 /* SyncTask.swift in Sources */, + 99D9958F2F71AB6800BAF216 /* JellyfinLibraryItem.swift in Sources */, 3FF5BA662E10EA6F00B82BCC /* HardcoverBook+CoreDataProperties.swift in Sources */, 62CADBAB2725C23A00A4A98F /* ArtworkService.swift in Sources */, 9F7B64772803B0B000895ECC /* AccountService.swift in Sources */, @@ -5051,6 +5389,8 @@ 4138CE1126E5542C0014F11E /* Bookmark+CoreDataClass.swift in Sources */, 41A90C4827564DAA00C30394 /* BookPlayerError.swift in Sources */, 41C23401272E1960006BC7B8 /* SimpleTheme.swift in Sources */, + 992D6E132FA98B4D0091849F /* AudiobookShelfConnectionService.swift in Sources */, + 992D6E142FA98B4D0091849F /* AudiobookShelfConnectionData.swift in Sources */, 41A1B124226F88C500EA0400 /* Book+CoreDataProperties.swift in Sources */, 3FF5BA692E10EA8000B82BCC /* HardcoverBook+CoreDataClass.swift in Sources */, 41A1B108226E9DF800EA0400 /* DataManager.swift in Sources */, @@ -5060,16 +5400,22 @@ 9FDDD2DE289BEE590020C428 /* SyncJobScheduler.swift in Sources */, 41A8942A2652A7DF0032E972 /* Bundle+BookPlayer.swift in Sources */, 41A1B0F2226E9D1200EA0400 /* BookPlayer.xcdatamodeld in Sources */, + 99F3C2152F65174700AC91A2 /* SimpleExternalResource.swift in Sources */, 3F66408D2E172DF500356522 /* MappingModel_v9_to_v10.xcmappingmodel in Sources */, 99F3C1F82F5F873900AC91A2 /* UUIDMigrationPolicy.swift in Sources */, 41A359C5276232E00020D5F5 /* MappingModel_v7_to_v8.xcmappingmodel in Sources */, 41A1B125226F88C500EA0400 /* LibraryItem+CoreDataClass.swift in Sources */, 62CADBA52725BCB200A4A98F /* AVAudioAssetImageDataProvider.swift in Sources */, 9FC1E4592814E0B000522FA8 /* NetworkUtils.swift in Sources */, + 9911FBB52F72486D00DC9A2B /* ExternalUpdateProgressOperation.swift in Sources */, + 992D6E192FA98C8F0091849F /* AudiobookShelfLibraryLevelData.swift in Sources */, + 99D995892F719E7E00BAF216 /* (null) in Sources */, 41C23403272E1965006BC7B8 /* PlaybackState.swift in Sources */, 99F3C2052F5FC76D00AC91A2 /* MigrationPlan.swift in Sources */, + 992D6E1B2FA98CBE0091849F /* AudiobookShelfLibrary.swift in Sources */, 41A1B120226F88C500EA0400 /* Chapter+CoreDataProperties.swift in Sources */, 41CE44A9227215B500C900AF /* Notification+BookPlayerKit.swift in Sources */, + 99F3C2422F6E322800AC91A2 /* SchemaV3SyncTasksModels.swift in Sources */, 41C233FF272E195B006BC7B8 /* SimpleItemType.swift in Sources */, 9FDDD2D8289B64440020C428 /* SyncStatus.swift in Sources */, 9FDDD2E1289BFCE20020C428 /* LibraryService+Sync.swift in Sources */, @@ -5078,6 +5424,7 @@ 6308260A2AF5B66D002ACE0D /* UserDefaults+BookPlayer.swift in Sources */, FEEDBABE0000000000000003 /* SharedWidgetStore.swift in Sources */, 63398E3E2D68254000A6934E /* SimplePlaybackRecord.swift in Sources */, + 99F3C2342F6BBECC00AC91A2 /* JellyfinConnectionData.swift in Sources */, 4140EA1F22723CFC0009F794 /* CommandParser.swift in Sources */, 6356F9CE2AC8A1CE00B7A027 /* DatabaseInitializer.swift in Sources */, 41EB071B2752FA6B00EFEE13 /* PlaybackService.swift in Sources */, @@ -5092,18 +5439,21 @@ 9FBDBC7D287940D900D315A2 /* ContentsResponse.swift in Sources */, 9F07B79829B770B7005A939D /* BPTaskUploadDelegate.swift in Sources */, 41C3394925E04091003ED2B0 /* MappingModel_v2_to_v3.xcmappingmodel in Sources */, + 996D4CD12F98A45300FC3B57 /* IntegrationError.swift in Sources */, 41640A452441ACE2004FB97B /* Intents.intentdefinition in Sources */, 41D20DB225D5F5A100AAEE30 /* MappingModel_v1_to_v2.xcmappingmodel in Sources */, 412AB71527017ABE00969618 /* DBVersion.swift in Sources */, + 99F3C2122F650F1200AC91A2 /* ExternalResource+CoreDataProperties.swift in Sources */, 9F6BC86627E6D03D002CF2A6 /* WatchApplicationContext.swift in Sources */, 4138CE1326E554320014F11E /* Bookmark+CoreDataProperties.swift in Sources */, 41A1B123226F88C500EA0400 /* Book+CoreDataClass.swift in Sources */, 41A1B105226E9DBD00EA0400 /* UIColor+Sweetercolor.swift in Sources */, 9FF383D32A40F97000BBAC11 /* MappingModel_v8_to_v9.xcmappingmodel in Sources */, - 9F2E00922A568F800001FE20 /* IdentifiersResponse.swift in Sources */, + 9F2E00922A568F800001FE20 /* ItemsStatusResponse.swift in Sources */, 41A1B10C226E9E9700EA0400 /* DeleteMode.swift in Sources */, 9FC1E44A2814C4DE00522FA8 /* NetworkClient.swift in Sources */, 9F9C7B5029F9670100E257B0 /* BookmarksResponse.swift in Sources */, + 9911FBC82F7738CC00DC9A2B /* SyncableExternalResource.swift in Sources */, 41EB07182752F1BA00EFEE13 /* PlayableChapter.swift in Sources */, 9F1345BA2938E0130089B1DE /* Spacing.swift in Sources */, 638352CA2E67F327009EE79E /* TasksDataManager.swift in Sources */, @@ -5111,28 +5461,33 @@ 638352CD2E67F327009EE79E /* SwiftDataModels+Extensions.swift in Sources */, 5126F122258E9F18009965DC /* URL+BookPlayer.swift in Sources */, 9F56C84D287B734C00EA9751 /* BPLogger.swift in Sources */, - 632132502F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel in Sources */, 418CABB425EF28FC00D8C878 /* MappingModel_v3_to_v4.xcmappingmodel in Sources */, + 99D995962F71ABBD00BAF216 /* JellyfinAudiobookDetailsData.swift in Sources */, 41A1B121226F88C500EA0400 /* PlaybackRecord+CoreDataClass.swift in Sources */, 99F3C1ED2F5BCE1600AC91A2 /* LibraryItemRef.swift in Sources */, 9F9F3B70280E4E4B004359DA /* SyncService.swift in Sources */, 41A1B104226E9DBA00EA0400 /* UIColor+BookPlayer.swift in Sources */, 9F7B64712803216100895ECC /* Account+CoreDataProperties.swift in Sources */, + 992D6E1D2FA98E1A0091849F /* AudiobookShelfAudiobookDetailsData.swift in Sources */, 639AC9892AD9F1D50053AFC6 /* BPDownloadURLSession.swift in Sources */, 9F7B64742803217400895ECC /* Account+CoreDataClass.swift in Sources */, + 9911FBB02F71D03800DC9A2B /* SyncQueueService.swift in Sources */, 63E1FF922E6C8C2000F2E003 /* SyncJobType.swift in Sources */, 99F3C20A2F62285800AC91A2 /* ApiResponse.swift in Sources */, + 99D995872F719E7600BAF216 /* JellyfinConnectionService.swift in Sources */, 9FBDBC802879C6C900D315A2 /* UploadItemResponse.swift in Sources */, + BEC7B87D99BB26DE10DEEBC7 /* MultipartUploadResponse.swift in Sources */, 41A1B11D226F88C500EA0400 /* Theme+CoreDataClass.swift in Sources */, 41A894202652A5DE0032E972 /* Configuration.swift in Sources */, + 99F3C20F2F650F0900AC91A2 /* ExternalResource+CoreDataClass.swift in Sources */, 41A90C4527563F5A00C30394 /* ItemType.swift in Sources */, 41A1B129226F88C500EA0400 /* Folder+CoreDataClass.swift in Sources */, 41C233FD272E1957006BC7B8 /* SimpleLibraryItem.swift in Sources */, 3FA43BF82E110B4900DF9B1F /* SimpleHardcoverBook.swift in Sources */, 41A1B12A226F88C500EA0400 /* Folder+CoreDataProperties.swift in Sources */, - 638E64CE2B8E1CFD00DCFA3B /* SyncTasksCountService.swift in Sources */, + 9911FBAD2F71CD6300DC9A2B /* AsyncOperation.swift in Sources */, 99F3C1FE2F5FC2F200AC91A2 /* SchemaV1SyncTasksModels.swift in Sources */, - 63C6C30D2B538D8500FFE0D8 /* SyncTasksStorage.swift in Sources */, + 9911FBB32F71E54C00DC9A2B /* SyncQueueRepository.swift in Sources */, 3F66408A2E162ABF00356522 /* AudioMetadataService.swift in Sources */, 41EB07152752F17B00EFEE13 /* PlayableItem.swift in Sources */, 41A1B11E226F88C500EA0400 /* Theme+CoreDataProperties.swift in Sources */, @@ -5140,11 +5495,23 @@ 99F3C2082F5FD41300AC91A2 /* SchemaV2SyncTasksModels.swift in Sources */, DCFC79BEBAEA628FB13F33A1 /* BPFont.swift in Sources */, 63AD93D32E3C1BA70077E73A /* SimpleAccount.swift in Sources */, + 63673F9B304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel in Sources */, + 99C919CF2F99A8C100F66081 /* IntegrationDetailsViewModelProtocol.swift in Sources */, 41A1B127226F88C500EA0400 /* Library+CoreDataClass.swift in Sources */, 9F0872DB2A19867C00B7FD4A /* ArtworkResponse.swift in Sources */, FF95F8C692908D744A32D761 /* PreferencesAPI.swift in Sources */, A13B98C548AA6BFD0E6FC0C7 /* PreferencesSyncService.swift in Sources */, 52AA441379FD94339FEECB55 /* SortPreferencesResolving.swift in Sources */, + FAE65B9ED5048BF9DD783BEF /* AudiobookShelfOIDCFlow.swift in Sources */, + 2260D77EDD8BEEB0D433CD18 /* IntegrationHTTPClient.swift in Sources */, + FED6216CFABF04A613866335 /* WebAuthenticating.swift in Sources */, + 5E699DCEB4EB06CB0A18A770 /* IntegrationConnectionPayload.swift in Sources */, + F575EA7BBE6C619D27225281 /* IntegrationConnectionStore.swift in Sources */, + 77980C5A9B7CF1A948FE7CF1 /* PKCE.swift in Sources */, + B16A2A4FFFE86D46E799A773 /* IntegrationHostResolver.swift in Sources */, + 09E50F19D8C014240BD8A1BF /* ExternalStreamResolver.swift in Sources */, + CF9C88BC3DDD1AE5833E2106 /* ExternalProgressProviding.swift in Sources */, + CDA937D56B8AFBA03402E979 /* ExternalProgressService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5155,8 +5522,8 @@ 630826012AF29538002ACE0D /* SharedWidgetEntry.swift in Sources */, 6340D5692AF12D48003D0B09 /* SharedWidget.swift in Sources */, 63005A2D2AE7FD8000A4CA2C /* Intents.intentdefinition in Sources */, + 63673F98304332050077F1D8 /* MappingModel_v11_to_v12.xcmappingmodel in Sources */, 630825FE2AF293DC002ACE0D /* SharedWidgetContainerView.swift in Sources */, - 632132512F8AB05B0044353B /* MappingModel_v10_to_v11.xcmappingmodel in Sources */, 630825FC2AF2923B002ACE0D /* WidgetUtils.swift in Sources */, 630826122AF6CA45002ACE0D /* SharedIconWidget.swift in Sources */, 3F66408E2E172DF500356522 /* MappingModel_v9_to_v10.xcmappingmodel in Sources */, @@ -5166,6 +5533,7 @@ 63005A302AE7FD8000A4CA2C /* BookPlayerWidgets.swift in Sources */, 63005A312AE7FD8000A4CA2C /* MappingModel_v1_to_v2.xcmappingmodel in Sources */, 63F828572AED56FA00B5CE0C /* CornerView.swift in Sources */, + 996D4CC82F98A25100FC3B57 /* MappingModel_v10_to_v11.xcmappingmodel in Sources */, 63005A342AE7FD8000A4CA2C /* MappingModel_v2_to_v3.xcmappingmodel in Sources */, 6340D56E2AF13E4C003D0B09 /* RectangularView.swift in Sources */, 632941402AEEE739000AD2EE /* CircularView.swift in Sources */, @@ -5390,7 +5758,7 @@ DEVELOPMENT_TEAM = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = BookPlayerIntents/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5424,7 +5792,7 @@ DEVELOPMENT_TEAM = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = BookPlayerIntents/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5456,7 +5824,7 @@ DEVELOPMENT_TEAM = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = BookPlayerIntents/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5740,7 +6108,7 @@ DEVELOPMENT_TEAM = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = BookPlayerWidgets/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5778,7 +6146,7 @@ DEVELOPMENT_TEAM = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = BookPlayerWidgets/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5814,7 +6182,7 @@ DEVELOPMENT_TEAM = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = BookPlayerWidgets/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -5881,7 +6249,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = BookPlayer; @@ -5938,7 +6306,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_NAME = BookPlayer; SDKROOT = iphoneos; @@ -5972,7 +6340,7 @@ ); INFOPLIST_FILE = BookPlayer/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6010,7 +6378,7 @@ ); INFOPLIST_FILE = BookPlayer/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6040,7 +6408,7 @@ CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = "$(inherited)"; INFOPLIST_FILE = BookPlayerTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6066,7 +6434,7 @@ DEVELOPMENT_TEAM = "$(inherited)"; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = S7TJSJXWUZ; INFOPLIST_FILE = BookPlayerTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6105,7 +6473,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = BookPlayerKit/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6151,7 +6519,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = BookPlayerKit/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6195,7 +6563,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = BookPlayerKit/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6346,7 +6714,7 @@ INFOPLIST_FILE = BookPlayerShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BookPlayerShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 BookPlayer LLC. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6386,7 +6754,7 @@ INFOPLIST_FILE = BookPlayerShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BookPlayerShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 BookPlayer LLC. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6424,7 +6792,7 @@ INFOPLIST_FILE = BookPlayerShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BookPlayerShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 BookPlayer LLC. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6486,7 +6854,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_NAME = BookPlayer; SDKROOT = iphoneos; @@ -6519,7 +6887,7 @@ ); INFOPLIST_FILE = BookPlayer/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books"; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6550,7 +6918,7 @@ DEVELOPMENT_TEAM = "$(inherited)"; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = S7TJSJXWUZ; INFOPLIST_FILE = BookPlayerTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -6712,14 +7080,6 @@ version = 8.36.0; }; }; - 41F1A214254B0AA40043FCF3 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/onevcat/Kingfisher.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 7.9.0; - }; - }; 41F1A226254B0C6C0043FCF3 /* XCRemoteSwiftPackageReference "ZipArchive" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/GianniCarlo/ZipArchive.git"; @@ -6744,6 +7104,22 @@ minimumVersion = 0.4.0; }; }; + 990775752FF33BD700E35FAE /* XCRemoteSwiftPackageReference "Get" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kean/Get.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.2.1; + }; + }; + 9907757A2FF33C0C00E35FAE /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/onevcat/Kingfisher.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.10.0; + }; + }; 9FE8188D2805BCE40089A41E /* XCRemoteSwiftPackageReference "purchases-ios" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/RevenueCat/purchases-ios.git"; @@ -6785,22 +7161,47 @@ package = 41F1A226254B0C6C0043FCF3 /* XCRemoteSwiftPackageReference "ZipArchive" */; productName = ZipArchive; }; - 62CADBA72725BCE800A4A98F /* Kingfisher */ = { + 63AE44722E6161B60088668F /* DirectoryWatcher */ = { isa = XCSwiftPackageProductDependency; - package = 41F1A214254B0AA40043FCF3 /* XCRemoteSwiftPackageReference "Kingfisher" */; + package = 63AE44712E6161B60088668F /* XCRemoteSwiftPackageReference "DirectoryWatcher" */; + productName = DirectoryWatcher; + }; + 8A495CC52CC85D27001B4244 /* JellyfinAPI */ = { + isa = XCSwiftPackageProductDependency; + package = 8A495CC42CC8589F001B4244 /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; + productName = JellyfinAPI; + }; + 990775762FF33BD700E35FAE /* Get */ = { + isa = XCSwiftPackageProductDependency; + package = 990775752FF33BD700E35FAE /* XCRemoteSwiftPackageReference "Get" */; + productName = Get; + }; + 990775782FF33BE100E35FAE /* Get */ = { + isa = XCSwiftPackageProductDependency; + package = 990775752FF33BD700E35FAE /* XCRemoteSwiftPackageReference "Get" */; + productName = Get; + }; + 9907757B2FF33C0C00E35FAE /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = 9907757A2FF33C0C00E35FAE /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; - 62CADBA92725BCF000A4A98F /* Kingfisher */ = { + 9907757D2FF33C1500E35FAE /* Kingfisher */ = { isa = XCSwiftPackageProductDependency; - package = 41F1A214254B0AA40043FCF3 /* XCRemoteSwiftPackageReference "Kingfisher" */; + package = 9907757A2FF33C0C00E35FAE /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; - 63AE44722E6161B60088668F /* DirectoryWatcher */ = { + 9907757F2FF33D5300E35FAE /* Get */ = { isa = XCSwiftPackageProductDependency; - package = 63AE44712E6161B60088668F /* XCRemoteSwiftPackageReference "DirectoryWatcher" */; - productName = DirectoryWatcher; + package = 990775752FF33BD700E35FAE /* XCRemoteSwiftPackageReference "Get" */; + productName = Get; }; - 8A495CC52CC85D27001B4244 /* JellyfinAPI */ = { + 99D995972F71B02D00BAF216 /* JellyfinAPI */ = { + isa = XCSwiftPackageProductDependency; + package = 8A495CC42CC8589F001B4244 /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; + productName = JellyfinAPI; + }; + 99D995992F71B05700BAF216 /* JellyfinAPI */ = { isa = XCSwiftPackageProductDependency; package = 8A495CC42CC8589F001B4244 /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; productName = JellyfinAPI; @@ -6821,6 +7222,7 @@ 4165EDF920A743D500616EDF /* BookPlayer.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + 99F3C20C2F64ED1900AC91A2 /* Audiobook Player 12.xcdatamodel */, 995787DD2F56674400BF0814 /* Audiobook Player 11.xcdatamodel */, 3FF8EA3D2E11C7980054CD2C /* Audiobook Player 10.xcdatamodel */, 9F7B646F28031E9300895ECC /* Audiobook Player 9.xcdatamodel */, @@ -6833,7 +7235,7 @@ 41D20DAD25D5F0CA00AAEE30 /* Audiobook Player 2.xcdatamodel */, 4165EDFA20A743D500616EDF /* Audiobook Player.xcdatamodel */, ); - currentVersion = 995787DD2F56674400BF0814 /* Audiobook Player 11.xcdatamodel */; + currentVersion = 99F3C20C2F64ED1900AC91A2 /* Audiobook Player 12.xcdatamodel */; path = BookPlayer.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/BookPlayer.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/BookPlayer.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000..0c67376eb --- /dev/null +++ b/BookPlayer.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/BookPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/BookPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cde4a7949..801befa9e 100644 --- a/BookPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/BookPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "318e5e6f1fd44dd83a98bfdcc02c25efa21b9ba400d81e74fd9a319d50412e50", + "originHash" : "805dd10f49bb52f869285787fba58f83273717c272d6c319c6faa7d81b013cfc", "pins" : [ { "identity" : "devicekit", @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher.git", "state" : { - "revision" : "2ef543ee21d63734e1c004ad6c870255e8716c50", - "version" : "7.12.0" + "revision" : "410984bf301f4fa224fe56277b3f8672cc465c79", + "version" : "8.11.0" } }, { diff --git a/BookPlayer/AppDelegate.swift b/BookPlayer/AppDelegate.swift index 770d6d25b..7481c5a1f 100644 --- a/BookPlayer/AppDelegate.swift +++ b/BookPlayer/AppDelegate.swift @@ -22,7 +22,7 @@ import WatchConnectivity @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate, BPLogger { - static weak var shared: AppDelegate? + static weak var shared: AppDelegate? var window: UIWindow? @@ -48,6 +48,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, BPLogger { /// Database backup task identifier private lazy var databaseBackupTaskIdentifier = "\(Bundle.main.configurationString(for: .bundleIdentifier)).background.database.backup" + /// Answers iOS's relaunches and wakes for the background transfer sessions + private lazy var backgroundSessionWakes = BackgroundSessionWakeCoordinator() func application( _ application: UIApplication, @@ -63,6 +65,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate, BPLogger { Constants.UserDefaults.videoPictureInPictureEnabled: false, ]) + // Hardcover settings live in the shared (app group) suite. Register their defaults + // there so the service (`bool/object(forKey:)`) and the settings UI (`@AppStorage`) + // resolve to the same value before the user explicitly saves anything. + UserDefaults.sharedDefaults.register(defaults: [ + Constants.UserDefaults.hardcoverAutoAddWantToRead: true, + Constants.UserDefaults.hardcoverReadingThreshold: 1.0, + ]) + NotificationCenter.default.addObserver( self, selector: #selector(self.messageReceived), @@ -70,6 +80,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, BPLogger { object: nil ) + // Before anything can wake a session: a session's "finished events" may arrive before + // iOS hands over its handler, and must not find no one listening + _ = backgroundSessionWakes // register background refresh tasks self.setupBackgroundRefreshTasks() // register for remote events @@ -81,9 +94,29 @@ class AppDelegate: UIResponder, UIApplicationDelegate, BPLogger { // Setup core services AppServices.shared.setupCoreServices() + // The extension-safe framework can't reach UIApplication.shared itself; the SSO flow's + // presentation anchor comes from here, the one process that owns windows. + WebAuthenticationSession.foregroundWindowProvider = { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + guard let scene = scenes.first(where: { $0.activationState == .foregroundActive }) else { + return nil + } + return scene.keyWindow ?? scene.windows.first + } + return true } + /// Stays on the app delegate with scenes (TN3187). The handler is called once the + /// session's events are handled — for uploads, once the next parts are queued. + func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + backgroundSessionWakes.handleEvents(forSession: identifier, completionHandler: completionHandler) + } + func application( _ application: UIApplication, handle intent: INIntent, @@ -412,6 +445,9 @@ extension AppDelegate { self.handleAppRefresh(task: refreshTask) } + // At launch, so a task iOS runs for an earlier submit finds its handler + UploadContinuationController.register { AppServices.shared.uploadContinuation } + BGTaskScheduler.shared.register( forTaskWithIdentifier: databaseBackupTaskIdentifier, using: nil @@ -429,13 +465,45 @@ extension AppDelegate { let request = BGAppRefreshTaskRequest(identifier: refreshTaskIdentifier) request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) - try? BGTaskScheduler.shared.submit(request) + Self.submitBackgroundTask(request) + } + + /// `submit(_:)` is deprecated from iOS 27 in favor of `submitTaskRequest`. + static func submitBackgroundTask( + _ request: BGTaskRequest, + completion: @escaping @Sendable ((any Error)?) -> Void = { _ in } + ) { + if #available(iOS 27, *) { + // Its header: not from the main thread + DispatchQueue.global(qos: .utility).async { + BGTaskScheduler.shared.submitTaskRequest(request, completionHandler: completion) + } + } else { + do { + try BGTaskScheduler.shared.submit(request) + completion(nil) + } catch { + completion(error) + } + } } func handleAppRefresh(task: BGAppRefreshTask) { - guard let syncService = AppServices.shared.coreServices?.syncService else { return } + guard let syncQueueService = AppServices.shared.coreServices?.syncQueueService else { return } - let refreshOperation = RefreshTaskOperation(syncService: syncService) + // A gated sync lane (sync off) never drains, so waiting on it would only hold the + // window open until expiration + guard syncQueueService.serverLanesEnabled else { + task.setTaskCompleted(success: true) + return + } + + // Sync lane only: metadata pushes are what the refresh window is for. Provider pushes + // retry forever against an unreachable home server, and S3 uploads already run on a + // background URLSession — neither should keep the process awake until expiration. + let refreshOperation = RefreshTaskOperation( + queueDrained: syncQueueService.observeQueueCounts().laneDrained(TaskQueueKey.sync) + ) refreshOperation.completionBlock = { [weak self] in let success = !refreshOperation.isCancelled @@ -486,11 +554,13 @@ extension AppDelegate { request.earliestBeginDate = Date(timeIntervalSinceNow: 24 * 60 * 60) } - do { - try BGTaskScheduler.shared.submit(request) - Self.logger.info("Database backup scheduled for: \(request.earliestBeginDate?.description ?? "unknown")") - } catch { - Self.logger.error("Failed to schedule database backup: \(error.localizedDescription)") + let scheduledFor = request.earliestBeginDate?.description ?? "unknown" + Self.submitBackgroundTask(request) { error in + if let error { + Self.logger.error("Failed to schedule database backup: \(error.localizedDescription)") + } else { + Self.logger.info("Database backup scheduled for: \(scheduledFor)") + } } } @@ -515,4 +585,4 @@ extension AppDelegate { queue.addOperation(backupOperation) } -} +} \ No newline at end of file diff --git a/BookPlayer/AppIntents/BPAppShortcuts.swift b/BookPlayer/AppIntents/BPAppShortcuts.swift index b32d7107f..8a43e1105 100644 --- a/BookPlayer/AppIntents/BPAppShortcuts.swift +++ b/BookPlayer/AppIntents/BPAppShortcuts.swift @@ -9,7 +9,7 @@ import Foundation import AppIntents -@available(iOS 16.4, macOS 14.0, watchOS 10.0, *) +@available(macOS 14.0, watchOS 10.0, *) struct BPAppShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { return [ diff --git a/BookPlayer/AppIntents/CancelSleepTimerIntent.swift b/BookPlayer/AppIntents/CancelSleepTimerIntent.swift index 07afdc520..170adddb2 100644 --- a/BookPlayer/AppIntents/CancelSleepTimerIntent.swift +++ b/BookPlayer/AppIntents/CancelSleepTimerIntent.swift @@ -9,7 +9,7 @@ import Foundation import AppIntents -@available(iOS 16.4, macOS 14.0, watchOS 10.0, *) +@available(macOS 14.0, watchOS 10.0, *) struct CancelSleepTimerIntent: AppIntent { static var title: LocalizedStringResource = "intent_sleeptimer_cancel" diff --git a/BookPlayer/AppIntents/EndChapterSleepTimerIntent.swift b/BookPlayer/AppIntents/EndChapterSleepTimerIntent.swift index 08af2cd7c..faaa32fdb 100644 --- a/BookPlayer/AppIntents/EndChapterSleepTimerIntent.swift +++ b/BookPlayer/AppIntents/EndChapterSleepTimerIntent.swift @@ -9,7 +9,7 @@ import Foundation import AppIntents -@available(iOS 16.4, macOS 14.0, watchOS 10.0, *) +@available(macOS 14.0, watchOS 10.0, *) struct EndChapterSleepTimerIntent: AppIntent { static var title: LocalizedStringResource = "intent_sleeptimer_eoc_title" diff --git a/BookPlayer/Assets.xcassets/audiobookshelf-icon.imageset/Contents.json b/BookPlayer/Assets.xcassets/audiobookshelf-icon.imageset/Contents.json deleted file mode 100644 index 90b58dbed..000000000 --- a/BookPlayer/Assets.xcassets/audiobookshelf-icon.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "abs.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "template-rendering-intent" : "template" - } -} diff --git a/BookPlayer/Assets.xcassets/audiobookshelf-icon.imageset/abs.svg b/BookPlayer/Assets.xcassets/audiobookshelf-icon.imageset/abs.svg deleted file mode 100644 index 54cb18495..000000000 --- a/BookPlayer/Assets.xcassets/audiobookshelf-icon.imageset/abs.svg +++ /dev/null @@ -1,31 +0,0 @@ - - -bgAsset 6 - - - - - - - - - - - - \ No newline at end of file diff --git a/BookPlayer/Assets.xcassets/audiobookshelf-icon.symbolset/Contents.json b/BookPlayer/Assets.xcassets/audiobookshelf-icon.symbolset/Contents.json new file mode 100644 index 000000000..b521ab8b1 --- /dev/null +++ b/BookPlayer/Assets.xcassets/audiobookshelf-icon.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "audiobookshelf.svg", + "idiom" : "universal" + } + ] +} diff --git a/BookPlayer/Assets.xcassets/audiobookshelf-icon.symbolset/audiobookshelf.svg b/BookPlayer/Assets.xcassets/audiobookshelf-icon.symbolset/audiobookshelf.svg new file mode 100644 index 000000000..f3c0a3c8a --- /dev/null +++ b/BookPlayer/Assets.xcassets/audiobookshelf-icon.symbolset/audiobookshelf.svg @@ -0,0 +1,72 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + Template v.4.0 + Requires Xcode 14 or greater + Generated from audiobookshelf-icon + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BookPlayer/Assets.xcassets/jellyfin-icon.imageset/jellyfin.svg b/BookPlayer/Assets.xcassets/jellyfin-icon.imageset/jellyfin.svg deleted file mode 100644 index 5c75f1c06..000000000 --- a/BookPlayer/Assets.xcassets/jellyfin-icon.imageset/jellyfin.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/BookPlayer/Assets.xcassets/jellyfin-icon.imageset/Contents.json b/BookPlayer/Assets.xcassets/jellyfin-icon.symbolset/Contents.json similarity index 61% rename from BookPlayer/Assets.xcassets/jellyfin-icon.imageset/Contents.json rename to BookPlayer/Assets.xcassets/jellyfin-icon.symbolset/Contents.json index 7385abcb2..7675d1c1c 100644 --- a/BookPlayer/Assets.xcassets/jellyfin-icon.imageset/Contents.json +++ b/BookPlayer/Assets.xcassets/jellyfin-icon.symbolset/Contents.json @@ -1,15 +1,12 @@ { - "images" : [ - { - "filename" : "jellyfin.svg", - "idiom" : "universal" - } - ], "info" : { "author" : "xcode", "version" : 1 }, - "properties" : { - "template-rendering-intent" : "template" - } + "symbols" : [ + { + "filename" : "jellyfin.svg", + "idiom" : "universal" + } + ] } diff --git a/BookPlayer/Assets.xcassets/jellyfin-icon.symbolset/jellyfin.svg b/BookPlayer/Assets.xcassets/jellyfin-icon.symbolset/jellyfin.svg new file mode 100644 index 000000000..d3e811bb6 --- /dev/null +++ b/BookPlayer/Assets.xcassets/jellyfin-icon.symbolset/jellyfin.svg @@ -0,0 +1,72 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + Template v.4.0 + Requires Xcode 14 or greater + Generated from jellyfin-icon + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BookPlayer/Assets.xcassets/plus/BookPlayerPlus.imageset/BookPlayerPlus.png b/BookPlayer/Assets.xcassets/plus/BookPlayerPlus.imageset/BookPlayerPlus.png deleted file mode 100644 index 343c57837..000000000 Binary files a/BookPlayer/Assets.xcassets/plus/BookPlayerPlus.imageset/BookPlayerPlus.png and /dev/null differ diff --git a/BookPlayer/Assets.xcassets/plus/BookPlayerPlus.imageset/BookPlayerPlus@2x.png b/BookPlayer/Assets.xcassets/plus/BookPlayerPlus.imageset/BookPlayerPlus@2x.png deleted file mode 100644 index 9d0217a41..000000000 Binary files a/BookPlayer/Assets.xcassets/plus/BookPlayerPlus.imageset/BookPlayerPlus@2x.png and /dev/null differ diff --git a/BookPlayer/Assets.xcassets/plus/BookPlayerPlus.imageset/BookPlayerPlus@3x.png b/BookPlayer/Assets.xcassets/plus/BookPlayerPlus.imageset/BookPlayerPlus@3x.png deleted file mode 100644 index 696f597bc..000000000 Binary files a/BookPlayer/Assets.xcassets/plus/BookPlayerPlus.imageset/BookPlayerPlus@3x.png and /dev/null differ diff --git a/BookPlayer/Assets.xcassets/plus/BookPlayerPlus.imageset/Contents.json b/BookPlayer/Assets.xcassets/plus/BookPlayerPlus.imageset/Contents.json deleted file mode 100644 index df83d1ba7..000000000 --- a/BookPlayer/Assets.xcassets/plus/BookPlayerPlus.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "BookPlayerPlus.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "BookPlayerPlus@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "BookPlayerPlus@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/BookPlayer/Assets.xcassets/plus/plusImageAppIcons.imageset/Contents.json b/BookPlayer/Assets.xcassets/plus/plusImageAppIcons.imageset/Contents.json deleted file mode 100644 index b0966e4ac..000000000 --- a/BookPlayer/Assets.xcassets/plus/plusImageAppIcons.imageset/Contents.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "plusImageAppIcons.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "plusImageAppIcons@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "plusImageAppIcons@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - }, - "properties" : { - "template-rendering-intent" : "template" - } -} \ No newline at end of file diff --git a/BookPlayer/Assets.xcassets/plus/plusImageAppIcons.imageset/plusImageAppIcons.png b/BookPlayer/Assets.xcassets/plus/plusImageAppIcons.imageset/plusImageAppIcons.png deleted file mode 100644 index dabe391a2..000000000 Binary files a/BookPlayer/Assets.xcassets/plus/plusImageAppIcons.imageset/plusImageAppIcons.png and /dev/null differ diff --git a/BookPlayer/Assets.xcassets/plus/plusImageAppIcons.imageset/plusImageAppIcons@2x.png b/BookPlayer/Assets.xcassets/plus/plusImageAppIcons.imageset/plusImageAppIcons@2x.png deleted file mode 100644 index e2b47033c..000000000 Binary files a/BookPlayer/Assets.xcassets/plus/plusImageAppIcons.imageset/plusImageAppIcons@2x.png and /dev/null differ diff --git a/BookPlayer/Assets.xcassets/plus/plusImageAppIcons.imageset/plusImageAppIcons@3x.png b/BookPlayer/Assets.xcassets/plus/plusImageAppIcons.imageset/plusImageAppIcons@3x.png deleted file mode 100644 index 336a7ab6d..000000000 Binary files a/BookPlayer/Assets.xcassets/plus/plusImageAppIcons.imageset/plusImageAppIcons@3x.png and /dev/null differ diff --git a/BookPlayer/Assets.xcassets/plus/plusImageSupport.imageset/Contents.json b/BookPlayer/Assets.xcassets/plus/plusImageSupport.imageset/Contents.json deleted file mode 100644 index 9aabc5e05..000000000 --- a/BookPlayer/Assets.xcassets/plus/plusImageSupport.imageset/Contents.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "plusImageSupport.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "plusImageSupport@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "plusImageSupport@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - }, - "properties" : { - "template-rendering-intent" : "template" - } -} \ No newline at end of file diff --git a/BookPlayer/Assets.xcassets/plus/plusImageSupport.imageset/plusImageSupport.png b/BookPlayer/Assets.xcassets/plus/plusImageSupport.imageset/plusImageSupport.png deleted file mode 100644 index f6a823b71..000000000 Binary files a/BookPlayer/Assets.xcassets/plus/plusImageSupport.imageset/plusImageSupport.png and /dev/null differ diff --git a/BookPlayer/Assets.xcassets/plus/plusImageSupport.imageset/plusImageSupport@2x.png b/BookPlayer/Assets.xcassets/plus/plusImageSupport.imageset/plusImageSupport@2x.png deleted file mode 100644 index 4bf60459c..000000000 Binary files a/BookPlayer/Assets.xcassets/plus/plusImageSupport.imageset/plusImageSupport@2x.png and /dev/null differ diff --git a/BookPlayer/Assets.xcassets/plus/plusImageSupport.imageset/plusImageSupport@3x.png b/BookPlayer/Assets.xcassets/plus/plusImageSupport.imageset/plusImageSupport@3x.png deleted file mode 100644 index e50ab933b..000000000 Binary files a/BookPlayer/Assets.xcassets/plus/plusImageSupport.imageset/plusImageSupport@3x.png and /dev/null differ diff --git a/BookPlayer/Assets.xcassets/plus/plusImageThemes.imageset/Contents.json b/BookPlayer/Assets.xcassets/plus/plusImageThemes.imageset/Contents.json deleted file mode 100644 index 5366e8695..000000000 --- a/BookPlayer/Assets.xcassets/plus/plusImageThemes.imageset/Contents.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "plusImageThemes.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "plusImageThemes@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "plusImageThemes@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - }, - "properties" : { - "template-rendering-intent" : "template" - } -} \ No newline at end of file diff --git a/BookPlayer/Assets.xcassets/plus/plusImageThemes.imageset/plusImageThemes.png b/BookPlayer/Assets.xcassets/plus/plusImageThemes.imageset/plusImageThemes.png deleted file mode 100644 index 53eee94b0..000000000 Binary files a/BookPlayer/Assets.xcassets/plus/plusImageThemes.imageset/plusImageThemes.png and /dev/null differ diff --git a/BookPlayer/Assets.xcassets/plus/plusImageThemes.imageset/plusImageThemes@2x.png b/BookPlayer/Assets.xcassets/plus/plusImageThemes.imageset/plusImageThemes@2x.png deleted file mode 100644 index 0eff9ecb8..000000000 Binary files a/BookPlayer/Assets.xcassets/plus/plusImageThemes.imageset/plusImageThemes@2x.png and /dev/null differ diff --git a/BookPlayer/Assets.xcassets/plus/plusImageThemes.imageset/plusImageThemes@3x.png b/BookPlayer/Assets.xcassets/plus/plusImageThemes.imageset/plusImageThemes@3x.png deleted file mode 100644 index ba3903527..000000000 Binary files a/BookPlayer/Assets.xcassets/plus/plusImageThemes.imageset/plusImageThemes@3x.png and /dev/null differ diff --git a/BookPlayer/Assets.xcassets/plus/support-no-padding.imageset/Contents.json b/BookPlayer/Assets.xcassets/plus/support-no-padding.imageset/Contents.json deleted file mode 100644 index 795b577de..000000000 --- a/BookPlayer/Assets.xcassets/plus/support-no-padding.imageset/Contents.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "images" : [ - { - "filename" : "support-no-padding.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "support-no-padding@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "support-no-padding@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "template-rendering-intent" : "template" - } -} diff --git a/BookPlayer/Assets.xcassets/plus/support-no-padding.imageset/support-no-padding.png b/BookPlayer/Assets.xcassets/plus/support-no-padding.imageset/support-no-padding.png deleted file mode 100644 index 69e542369..000000000 Binary files a/BookPlayer/Assets.xcassets/plus/support-no-padding.imageset/support-no-padding.png and /dev/null differ diff --git a/BookPlayer/Assets.xcassets/plus/support-no-padding.imageset/support-no-padding@2x.png b/BookPlayer/Assets.xcassets/plus/support-no-padding.imageset/support-no-padding@2x.png deleted file mode 100644 index d15c5f60d..000000000 Binary files a/BookPlayer/Assets.xcassets/plus/support-no-padding.imageset/support-no-padding@2x.png and /dev/null differ diff --git a/BookPlayer/Assets.xcassets/plus/support-no-padding.imageset/support-no-padding@3x.png b/BookPlayer/Assets.xcassets/plus/support-no-padding.imageset/support-no-padding@3x.png deleted file mode 100644 index f955cbb70..000000000 Binary files a/BookPlayer/Assets.xcassets/plus/support-no-padding.imageset/support-no-padding@3x.png and /dev/null differ diff --git a/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift b/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift index 374202d17..0796ae82c 100644 --- a/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift +++ b/BookPlayer/AudiobookShelf/AudiobookShelfRootView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import BookPlayerKit struct AudiobookShelfRootView: View { let connectionService: AudiobookShelfConnectionService @@ -22,11 +23,13 @@ struct AudiobookShelfRootView: View { } @EnvironmentObject private var singleFileDownloadService: SingleFileDownloadService + @EnvironmentObject private var externalImportEvents: ExternalImportEvents @EnvironmentObject private var theme: ThemeViewModel @Environment(\.dismiss) var dismiss @Environment(\.listState) private var listState - + @Environment(\.accountService) private var accountService + init(connectionService: AudiobookShelfConnectionService) { self.connectionService = connectionService self._connectionViewModel = .init( @@ -55,6 +58,8 @@ struct AudiobookShelfRootView: View { libraryTitle: resolvedLibrary?.title ?? "", connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + accountService: accountService, + onImportConfirmed: { externalImportEvents.send($0) }, onDismiss: { listState.activeIntegrationSheet = nil }, onSwitchLibrary: switchLibraryAction, dismissAll: dismiss @@ -69,6 +74,8 @@ struct AudiobookShelfRootView: View { libraryTitle: resolvedLibrary?.title ?? "", connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + accountService: accountService, + onImportConfirmed: { externalImportEvents.send($0) }, onDismiss: { listState.activeIntegrationSheet = nil }, onSwitchLibrary: switchLibraryAction, dismissAll: dismiss @@ -83,6 +90,8 @@ struct AudiobookShelfRootView: View { libraryTitle: resolvedLibrary?.title ?? "", connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + accountService: accountService, + onImportConfirmed: { externalImportEvents.send($0) }, onDismiss: { listState.activeIntegrationSheet = nil }, onSwitchLibrary: switchLibraryAction, dismissAll: dismiss @@ -97,6 +106,8 @@ struct AudiobookShelfRootView: View { libraryTitle: resolvedLibrary?.title ?? "", connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + accountService: accountService, + onImportConfirmed: { externalImportEvents.send($0) }, onDismiss: { listState.activeIntegrationSheet = nil }, onSwitchLibrary: switchLibraryAction, dismissAll: dismiss @@ -111,6 +122,8 @@ struct AudiobookShelfRootView: View { libraryTitle: resolvedLibrary?.title ?? "", connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + accountService: accountService, + onImportConfirmed: { externalImportEvents.send($0) }, onDismiss: { listState.activeIntegrationSheet = nil }, onSwitchLibrary: switchLibraryAction, dismissAll: dismiss @@ -297,7 +310,6 @@ struct AudiobookShelfRootView: View { } } - // MARK: - Per-Tab NavigationStack /// Each tab owns its own NavigationStack and BPNavigation. @@ -305,6 +317,8 @@ struct AudiobookShelfRootView: View { private struct AudiobookShelfTabRoot: View { let connectionService: AudiobookShelfConnectionService let singleFileDownloadService: SingleFileDownloadService + let accountService: AccountService + let onImportConfirmed: ([SimpleExternalResource]) -> Void let onDismiss: () -> Void var onSwitchLibrary: (() -> Void)? var dismissAll: DismissAction? @@ -321,12 +335,16 @@ private struct AudiobookShelfTabRoot: View { libraryTitle: String, connectionService: AudiobookShelfConnectionService, singleFileDownloadService: SingleFileDownloadService, + accountService: AccountService, + onImportConfirmed: @escaping ([SimpleExternalResource]) -> Void, onDismiss: @escaping () -> Void, onSwitchLibrary: (() -> Void)? = nil, dismissAll: DismissAction? = nil ) { self.connectionService = connectionService self.singleFileDownloadService = singleFileDownloadService + self.accountService = accountService + self.onImportConfirmed = onImportConfirmed self.dismissAll = dismissAll self.onDismiss = onDismiss self.onSwitchLibrary = onSwitchLibrary @@ -338,6 +356,8 @@ private struct AudiobookShelfTabRoot: View { source: source, connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + accountService: accountService, + onImportConfirmed: onImportConfirmed, navigation: navigation, navigationTitle: libraryTitle ) @@ -356,17 +376,24 @@ private struct AudiobookShelfTabRoot: View { source: source, connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + accountService: accountService, + onImportConfirmed: onImportConfirmed, navigation: navigation, navigationTitle: title ) ) case .details(let item): AudiobookShelfAudiobookDetailsView( - viewModel: AudiobookShelfAudiobookDetailsViewModel( - item: item, - connectionService: connectionService, - singleFileDownloadService: singleFileDownloadService - ) + initModel: { + AudiobookShelfAudiobookDetailsViewModel( + item: item, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + accountService: accountService, + onImportConfirmed: onImportConfirmed, + navigation: navigation + ) + } ) { onDismiss() } @@ -437,6 +464,13 @@ private struct AudiobookShelfTabRoot: View { .tint(theme.linkColor) .environmentObject(theme) } + .sheet(isPresented: $navigation.showingSubscribe) { + NavigationStack { + MediaServersLoginView() + } + .tint(theme.linkColor) + .environmentObject(theme) + } .task { navigation.dismiss = onDismiss } diff --git a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItem.swift b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItem.swift deleted file mode 100644 index f87657834..000000000 --- a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryItem.swift +++ /dev/null @@ -1,349 +0,0 @@ -// -// AudiobookShelfLibraryItem.swift -// BookPlayer -// -// Created by Gianni Carlo on 14/11/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. -// - -import Foundation - -struct AudiobookShelfSeriesReference: Codable, Hashable { - let id: String - let name: String - let sequence: String? -} - -struct AudiobookShelfLibraryItem: IntegrationLibraryItemProtocol, Codable { - enum Kind: String, Codable { - case audiobook = "book" - case podcast = "podcast" - case library = "library" - case browseCategory = "browseCategory" - case series = "series" - case collection = "collection" - case author = "author" - case narrator = "narrator" - } - - let id: String - let title: String - let kind: Kind - let libraryId: String - - // Metadata - let authorName: String? - let narratorName: String? - let duration: TimeInterval? - let size: Int64? - let subtitle: String? - let series: [AudiobookShelfSeriesReference]? - let addedAt: Int64? - let updatedAt: Int64? - - // Cover image - let coverPath: String? - let coverItemId: String? - - // Progress (if included) - let progress: Double? - let currentTime: TimeInterval? - let isFinished: Bool? - - // Browse metadata - let browseCategory: AudiobookShelfBrowseCategory? - let filter: AudiobookShelfItemFilter? - - init( - id: String, - title: String, - kind: Kind, - libraryId: String, - authorName: String? = nil, - narratorName: String? = nil, - duration: TimeInterval? = nil, - size: Int64? = nil, - subtitle: String? = nil, - series: [AudiobookShelfSeriesReference]? = nil, - addedAt: Int64? = nil, - updatedAt: Int64? = nil, - coverPath: String? = nil, - coverItemId: String? = nil, - progress: Double? = nil, - currentTime: TimeInterval? = nil, - isFinished: Bool? = nil, - browseCategory: AudiobookShelfBrowseCategory? = nil, - filter: AudiobookShelfItemFilter? = nil - ) { - self.id = id - self.title = title - self.kind = kind - self.libraryId = libraryId - self.authorName = authorName - self.narratorName = narratorName - self.duration = duration - self.size = size - self.subtitle = subtitle - self.series = series - self.addedAt = addedAt - self.updatedAt = updatedAt - self.coverPath = coverPath - self.coverItemId = coverItemId - self.progress = progress - self.currentTime = currentTime - self.isFinished = isFinished - self.browseCategory = browseCategory - self.filter = filter - } -} - -extension AudiobookShelfLibraryItem { - var displayName: String { title } - - var isDownloadable: Bool { - kind == .audiobook || kind == .podcast - } - - var isNavigable: Bool { - !isDownloadable - } - - var placeholderImageName: String { - switch kind { - case .podcast, .audiobook: "waveform" - case .library: "folder" - case .browseCategory: - switch browseCategory { - case .books: "books.vertical" - case .series: "rectangle.stack" - case .collections: "square.stack.3d.up" - case .authors: "person.2" - case .narrators: "mic" - case .none: "square.grid.2x2" - } - case .series: "rectangle.stack" - case .collection: "square.stack.3d.up" - case .author: "person" - case .narrator: "mic" - } - } - - func seriesSequence(for seriesID: String) -> String? { - series?.first(where: { $0.id == seriesID })?.sequence - } - - init(library: AudiobookShelfLibrary) { - self.init( - id: library.id, - title: library.name, - kind: .library, - libraryId: library.id, - subtitle: library.mediaType == "podcast" ? "Podcast library" : "Audiobook library" - ) - } - - init(category: AudiobookShelfBrowseCategory, libraryId: String) { - self.init( - id: category.rawValue, - title: category.title, - kind: .browseCategory, - libraryId: libraryId, - subtitle: "Browse by \(category.title.lowercased())", - browseCategory: category - ) - } - - init(author: AudiobookShelfLibraryFilterData.NamedEntity, libraryId: String) { - self.init( - id: author.id, - title: author.name, - kind: .author, - libraryId: libraryId, - subtitle: "Author", - filter: AudiobookShelfItemFilter(group: .authors, value: author.id, title: author.name) - ) - } - - init(series: AudiobookShelfLibraryFilterData.NamedEntity, libraryId: String) { - self.init( - id: series.id, - title: series.name, - kind: .series, - libraryId: libraryId, - subtitle: "Series", - filter: AudiobookShelfItemFilter(group: .series, value: series.id, title: series.name) - ) - } - - init(narrator: String, libraryId: String) { - self.init( - id: narrator, - title: narrator, - kind: .narrator, - libraryId: libraryId, - subtitle: "Narrator", - filter: AudiobookShelfItemFilter(group: .narrators, value: narrator, title: narrator) - ) - } - - init(collection: AudiobookShelfCollection) { - self.init( - id: collection.id, - title: collection.name, - kind: .collection, - libraryId: collection.libraryId, - subtitle: collection.description ?? "\(collection.books.count) books", - coverItemId: collection.books.first?.id - ) - } - - init?(apiItem: AudiobookShelfAPIItem) { - guard let mediaType = apiItem.mediaType, - let kind = Kind(rawValue: mediaType) else { - return nil - } - - self.init( - id: apiItem.id, - title: apiItem.media.metadata.title, - kind: kind, - libraryId: apiItem.libraryId, - authorName: apiItem.media.metadata.primaryAuthorName, - narratorName: apiItem.media.metadata.primaryNarratorName, - duration: apiItem.media.duration, - size: apiItem.size, - series: apiItem.media.metadata.series, - addedAt: apiItem.addedAt, - updatedAt: apiItem.updatedAt, - coverPath: apiItem.media.coverPath, - progress: apiItem.userMediaProgress?.progress, - currentTime: apiItem.userMediaProgress?.currentTime, - isFinished: apiItem.userMediaProgress?.isFinished - ) - } -} - -// MARK: - API Response Models - -struct AudiobookShelfAPIItem: Codable { - let id: String - let libraryId: String - let addedAt: Int64? - let updatedAt: Int64? - let mediaType: String? - let media: Media - let size: Int64? - let userMediaProgress: UserMediaProgress? - - struct Media: Codable { - let metadata: Metadata - let coverPath: String? - let duration: TimeInterval? - - struct Metadata: Codable { - let title: String - let authorName: String? - let narratorName: String? - let authors: [NamedEntity]? - let narrators: [String]? - let series: [AudiobookShelfSeriesReference]? - - enum CodingKeys: String, CodingKey { - case title - case authorName - case narratorName - case authors - case narrators - case series - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - title = try container.decode(String.self, forKey: .title) - authorName = try container.decodeIfPresent(String.self, forKey: .authorName) - narratorName = try container.decodeIfPresent(String.self, forKey: .narratorName) - authors = try container.decodeIfPresent([NamedEntity].self, forKey: .authors) - narrators = try container.decodeIfPresent([String].self, forKey: .narrators) - - if let seriesArray = try? container.decode([AudiobookShelfSeriesReference].self, forKey: .series) { - series = seriesArray - } else if let seriesSingle = try? container.decode(AudiobookShelfSeriesReference.self, forKey: .series) { - series = [seriesSingle] - } else { - series = nil - } - } - - var primaryAuthorName: String? { - authorName ?? authors?.first?.name - } - - var primaryNarratorName: String? { - narratorName ?? narrators?.first - } - } - - struct NamedEntity: Codable { - let id: String - let name: String - } - } - - struct UserMediaProgress: Codable { - let progress: Double - let currentTime: TimeInterval - let isFinished: Bool - } -} - -struct AudiobookShelfItemsResponse: Codable { - let results: [AudiobookShelfAPIItem] - let total: Int - let limit: Int? - let page: Int? -} - -struct AudiobookShelfSearchResponse: Codable { - let book: [SearchResult] - - struct SearchResult: Codable { - let libraryItem: AudiobookShelfAPIItem - } -} - -/// Response from `GET /api/authors/:id?include=items` (the endpoint the official -/// Vue web client uses for the author-detail page). `libraryItems` is hydrated -/// directly from the author record rather than via the `bookAuthors` join, which -/// avoids orphan-row matches that can occur after ABS dedups authors on import. -struct AudiobookShelfAuthorWithItemsResponse: Codable { - let id: String - let name: String - let libraryItems: [AudiobookShelfAPIItem]? -} - -struct AudiobookShelfLibraryFilterData: Codable { - let authors: [NamedEntity] - let genres: [String] - let tags: [String] - let series: [NamedEntity] - let narrators: [String] - let languages: [String] - - struct NamedEntity: Codable, Hashable { - let id: String - let name: String - } -} - -struct AudiobookShelfCollection: Codable { - let id: String - let libraryId: String - let name: String - let description: String? - let books: [AudiobookShelfAPIItem] -} - -struct AudiobookShelfCollectionsResponse: Codable { - let results: [AudiobookShelfCollection] -} diff --git a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryView.swift b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryView.swift index 918205861..b441466f5 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryView.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import BookPlayerKit /// Thin wrapper providing AudiobookShelf-specific cell, row, sort picker, and environment /// to the shared `IntegrationLibraryView`. @@ -39,9 +40,9 @@ where Model.Item == AudiobookShelfLibraryItem { Picker(selection: Binding( get: { vm.sortBy }, set: { vm.sortBy = $0 } - ), label: Text("Sort by".localized)) { + ), label: Text("sort_by_title")) { Label("sort_most_recent_button", systemImage: "clock").tag(AudiobookShelfLayout.SortBy.recent) - Label("Title".localized, systemImage: "textformat.abc").tag(AudiobookShelfLayout.SortBy.title) + Label("title_button", systemImage: "textformat.abc").tag(AudiobookShelfLayout.SortBy.title) } } } diff --git a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift index 1d5377f1b..6bc278b19 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryViewModel.swift @@ -19,6 +19,9 @@ enum AudiobookShelfLayout { @MainActor final class AudiobookShelfLibraryViewModel: IntegrationLibraryViewModelProtocol, BPLogger { + let onImportConfirmed: ([SimpleExternalResource]) -> Void + var accountService: AccountService + enum Routes { case done } @@ -41,10 +44,13 @@ final class AudiobookShelfLibraryViewModel: IntegrationLibraryViewModelProtocol, @Published var items: [AudiobookShelfLibraryItem] = [] @Published var totalItems = Int.max @Published var error: Error? + @Published private(set) var isImporting = false + @Published var pendingImportBatch: ExternalImportBatch? @Published var editMode: EditMode = .inactive @Published var selectedItems: Set = [] - + @Published var showingDownloadConfirmation = false + var onTransition: BPTransition? let connectionService: AudiobookShelfConnectionService @@ -122,12 +128,16 @@ final class AudiobookShelfLibraryViewModel: IntegrationLibraryViewModelProtocol, source: AudiobookShelfLibraryViewSource, connectionService: AudiobookShelfConnectionService, singleFileDownloadService: SingleFileDownloadService, + accountService: AccountService, + onImportConfirmed: @escaping ([SimpleExternalResource]) -> Void, navigation: BPNavigation, navigationTitle: String ) { self.source = source self.connectionService = connectionService self.singleFileDownloadService = singleFileDownloadService + self.accountService = accountService + self.onImportConfirmed = onImportConfirmed self.navigation = navigation self.navigationTitle = navigationTitle @@ -166,7 +176,7 @@ final class AudiobookShelfLibraryViewModel: IntegrationLibraryViewModelProtocol, func destination(for item: AudiobookShelfLibraryItem) -> AudiobookShelfLibraryLevelData? { switch item.kind { - case .audiobook, .podcast: + case .audiobook: return .details(data: item) case .library: return nil @@ -222,6 +232,98 @@ final class AudiobookShelfLibraryViewModel: IntegrationLibraryViewModelProtocol, selectedItems.removeAll() } } + + /// Stream never downloads: without the entitlement it sells the entitlement. + @MainActor + func onStreamTapped(useSelectedItems: Bool) { + guard accountService.hasStreamingEnabled() else { + goToSubscribe() + return + } + Task { await virtualImportFolderAudiobooks(useSelectedItems: useSelectedItems) } + } + + @MainActor + func virtualImportFolderAudiobooks(useSelectedItems: Bool) async { + // Reentrancy guard: a double-tap mid-hydration must not run two imports + guard !isImporting else { return } + // Both branches filter on isDownloadable so a selection can never carry something + // the whole-level branch would have skipped + let audiobooks = useSelectedItems + ? selectedItems.compactMap({ id in + self.items.first(where: { $0.id == id && $0.isDownloadable }) + }) + : self.items.filter { $0.isDownloadable } + + guard !audiobooks.isEmpty else { return } + isImporting = true + defer { isImporting = false } + + do { + let resources = try await VirtualImportPipeline.run( + items: audiobooks, + id: \.id, + hydrate: { ids in + // batch/get returns EXPANDED items (list endpoints are minified without + // audioFiles); one round-trip for the whole selection + let hydrated = try await self.connectionService.fetchItems(ids: ids) + return hydrated.reduce(into: [:]) { + $0[$1.id] = HydratedItem( + fileExtension: $1.fileExtension, + duration: $1.duration, + chapters: $1.chapters + ) + } + }, + buildResource: { item, hydrated in + item.asVirtualImportResource( + fileExtension: hydrated.fileExtension, + duration: hydrated.duration, + chapters: hydrated.chapters, + connectionService: self.connectionService, + artworkSize: CGSize(width: 300, height: 300) + ) + } + ) + guard !resources.isEmpty else { + self.error = BookPlayerError.runtimeError("import_no_audio_files_alert".localized) + return + } + if resources.count < audiobooks.count { + Self.logger.warning("Virtual import skipped \(audiobooks.count - resources.count) item(s) with no audio-file metadata or no server-measured length") + } + // Stage as a VALUE for this screen's own confirmation sheet — the browser + // stays open beneath it; dismissal happens on confirm + pendingImportBatch = ExternalImportBatch(resources: resources) + } catch { + self.error = error + } + } + + @MainActor + func onDownloadFolderTapped() { + showingDownloadConfirmation = true + } + + @MainActor + func confirmDownloadFolder() { + var requests = [URLRequest]() + // Same filter as virtualImportFolderAudiobooks: folder/collection rows at this + // level would each throw in createItemDownloadRequest (last error wins) while + // the rest of the loop proceeds — a confusing partial download + for item in self.items where item.isDownloadable { + do { + let request = try connectionService.createItemDownloadRequest(item) + requests.append(request) + } catch { + self.error = error + } + } + + guard !requests.isEmpty else { return } + singleFileDownloadService.handleDownload(requests) + navigation.dismiss?() + } @MainActor func onDownloadTapped() { @@ -243,6 +345,19 @@ final class AudiobookShelfLibraryViewModel: IntegrationLibraryViewModelProtocol, singleFileDownloadService.handleDownload(requests) navigation.dismiss?() } + + @MainActor + func goToSubscribe() { + navigation.showingSubscribe = true + } + + @MainActor + func confirmExternalImport(_ resources: [SimpleExternalResource]) { + // Bulk imports land you in the library (today's destination): send the batch on + // the import bus, then close the browser + onImportConfirmed(resources) + navigation.dismiss?() + } private func handleSortChanged() { guard !items.isEmpty else { return } diff --git a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsView.swift b/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsView.swift index 81d5ed6f5..3540fe8dd 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsView.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import BookPlayerKit /// Thin wrapper providing AudiobookShelf-specific image view to the shared details view. struct AudiobookShelfAudiobookDetailsView< @@ -14,9 +15,23 @@ struct AudiobookShelfAudiobookDetailsView< >: View where Model.Item == AudiobookShelfLibraryItem, Model.Details == AudiobookShelfAudiobookDetailsData { - @ObservedObject var viewModel: Model + /// OWNS the VM (repo pattern, see ItemListView.init(initModel:)): this wrapper is + /// re-created whenever the root's navigationDestination builder re-evaluates (e.g. + /// tapping Stream mutates importManager, an @EnvironmentObject of the root), and an + /// @ObservedObject over an inline-constructed VM was recreated each time — losing + /// error/isImporting state and refetching. @StateObject storage survives; the + /// initModel closure runs only on first install. + @StateObject var viewModel: Model var onDownloadTap: () -> Void + init( + initModel: @escaping () -> Model, + onDownloadTap: @escaping () -> Void + ) { + self._viewModel = .init(wrappedValue: initModel()) + self.onDownloadTap = onDownloadTap + } + var body: some View { IntegrationAudiobookDetailsView( viewModel: viewModel, diff --git a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsViewModel.swift b/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsViewModel.swift index 1eb2d503e..77cfc2d36 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsViewModel.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsViewModel.swift @@ -7,14 +7,34 @@ // import BookPlayerKit +import Combine import Foundation class AudiobookShelfAudiobookDetailsViewModel: IntegrationDetailsViewModelProtocol { - let item: AudiobookShelfLibraryItem let connectionService: AudiobookShelfConnectionService + let accountService: AccountService + let onImportConfirmed: ([SimpleExternalResource]) -> Void + let navigation: BPNavigation @Published var details: AudiobookShelfAudiobookDetailsData? @Published var error: Error? + @Published private(set) var isImporting = false + @Published var pendingImportBatch: ExternalImportBatch? + private var disposeBag = Set() + + var allowStream: Bool { accountService.hasStreamingEnabled() } + + @MainActor + func confirmExternalImport(_ resources: [SimpleExternalResource]) { + // Details imports keep you in the browser (today's flow — import another book + // without re-navigating): send on the import bus, no dismissal + onImportConfirmed(resources) + } + + @MainActor + func goToSubscribe() { + navigation.showingSubscribe = true + } private var singleFileDownloadService: SingleFileDownloadService private var fetchTask: Task<(), any Error>? @@ -22,11 +42,24 @@ class AudiobookShelfAudiobookDetailsViewModel: IntegrationDetailsViewModelProtoc init( item: AudiobookShelfLibraryItem, connectionService: AudiobookShelfConnectionService, - singleFileDownloadService: SingleFileDownloadService + singleFileDownloadService: SingleFileDownloadService, + accountService: AccountService, + onImportConfirmed: @escaping ([SimpleExternalResource]) -> Void, + navigation: BPNavigation ) { self.item = item self.connectionService = connectionService self.singleFileDownloadService = singleFileDownloadService + self.accountService = accountService + self.onImportConfirmed = onImportConfirmed + self.navigation = navigation + + // Entitlements can change while this screen is on the stack (the Stream CTA + // pushes the subscribe flow) — re-render on account updates so the CTAs flip + NotificationCenter.default.publisher(for: .accountUpdate) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &disposeBag) } @MainActor @@ -65,4 +98,58 @@ class AudiobookShelfAudiobookDetailsViewModel: IntegrationDetailsViewModelProtoc let request = try connectionService.createItemDownloadRequest(item) singleFileDownloadService.handleDownload(request) } + + @MainActor + func handleImportAudiobook(_ item: AudiobookShelfLibraryItem) async throws { + if accountService.hasStreamingEnabled() { + await virtualImportAudiobook(item) + } else { + try beginDownloadAudiobook(item) + } + } + + @MainActor + func virtualImportAudiobook(_ item: AudiobookShelfLibraryItem) async { + // Reentrancy guard: a double-tap mid-hydration must not run two imports + guard !isImporting else { return } + isImporting = true + defer { isImporting = false } + + do { + let resources = try await VirtualImportPipeline.run( + items: [item], + id: \.id, + hydrate: { ids in + // Same contract as the bulk paths: list navigation hands us a MINIFIED + // item, so hydrate it for the real file extension — never guessed — and + // for the length, which an import is refused without + let hydrated = try await self.connectionService.fetchItems(ids: ids) + var hydratedByID: [String: HydratedItem] = [:] + hydratedByID[item.id] = HydratedItem( + fileExtension: hydrated.first?.fileExtension ?? item.fileExtension, + duration: hydrated.first?.duration ?? item.duration, + chapters: hydrated.first?.chapters ?? [] + ) + return hydratedByID + }, + buildResource: { item, hydrated in + item.asVirtualImportResource( + fileExtension: hydrated.fileExtension, + duration: hydrated.duration, + chapters: hydrated.chapters, + connectionService: self.connectionService, + artworkSize: CGSize(width: 300, height: 300) + ) + } + ) + guard !resources.isEmpty else { + self.error = BookPlayerError.runtimeError("import_no_audio_files_alert".localized) + return + } + // Stage as a VALUE for this screen's own confirmation sheet + pendingImportBatch = ExternalImportBatch(resources: resources) + } catch { + self.error = error + } + } } diff --git a/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridItemView.swift b/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridItemView.swift index 2ebf6b4e5..8d458fbd3 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridItemView.swift +++ b/BookPlayer/AudiobookShelf/Library Screen/GridLayout/AudiobookShelfLibraryGridItemView.swift @@ -15,7 +15,7 @@ struct AudiobookShelfLibraryGridItemView: View { @State var item: AudiobookShelfLibraryItem - @ScaledMetric var accessabilityScale: CGFloat = 1 + @ScaledMetric var accessibilityScale: CGFloat = 1 var isSelected: Bool @@ -50,7 +50,7 @@ struct AudiobookShelfLibraryGridItemView: View { @ViewBuilder private var libraryBadge: some View { ZStack { - Circle().strokeBorder(.foreground, lineWidth: 1 * accessabilityScale) + Circle().strokeBorder(.foreground, lineWidth: 1 * accessibilityScale) .background(Circle().fill(.background)) Image(systemName: item.placeholderImageName) .resizable() diff --git a/BookPlayer/AudiobookShelf/Network/AudiobookShelfLibrary.swift b/BookPlayer/AudiobookShelf/Network/AudiobookShelfLibrary.swift deleted file mode 100644 index 5184f5e03..000000000 --- a/BookPlayer/AudiobookShelf/Network/AudiobookShelfLibrary.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// AudiobookShelfLibrary.swift -// BookPlayer -// -// Created by Gianni Carlo on 14/11/25. -// Copyright © 2025 BookPlayer LLC. All rights reserved. -// - -import Foundation - -struct AudiobookShelfLibrary: Codable, Identifiable { - let id: String - let name: String - let folders: [Folder] - let displayOrder: Int - let icon: String - let mediaType: String // "book" or "podcast" - let provider: String - - struct Folder: Codable { - let id: String - let fullPath: String - let libraryId: String - } -} - -struct AudiobookShelfLibrariesResponse: Codable { - let libraries: [AudiobookShelfLibrary] -} diff --git a/BookPlayer/Base.lproj/Localizable.strings b/BookPlayer/Base.lproj/Localizable.strings index 878b83cfb..3dda605d7 100644 --- a/BookPlayer/Base.lproj/Localizable.strings +++ b/BookPlayer/Base.lproj/Localizable.strings @@ -177,8 +177,7 @@ "library_add_title" = "Add your first book"; "invalid_url_title" = "Invalid URL: %@"; "progress_title" = "Progress"; -"download_folder_confirmation_title" = "Download Folder"; -"download_folder_confirmation_message" = "Download all %d files from this folder?"; +"download_folder_confirmation_message" = "Download all %d files?"; "settings_siri_sleeptimer_title" = "Sleep Timer"; "active_title" = "On"; "voiceover_currently_playing_title" = "Currently playing %@ by %@"; @@ -253,6 +252,7 @@ "details_title" = "Details"; "download_title" = "Download"; "download_incomplete_error" = "The download was incomplete. Please try again."; +"download_prepare_error" = "These items couldn't be prepared for download."; "cancel_download_title" = "Cancel download"; "remove_downloaded_file_title" = "Remove from device"; "download_from_url_title" = "Download from URL"; @@ -300,6 +300,7 @@ "subscribe_title" = "Subscribe now"; "renewal_description" = "Renews automatically monthly"; "pro_welcome_title" = "Welcome to BookPlayer Pro!"; +"lite_welcome_title" = "Welcome to BookPlayer Lite!"; "pro_welcome_description" = "We'll start syncing your library when WiFi is available"; "benefits_disclaimer_watch_description" = "- Stand-alone playback on the Apple Watch is not available yet. It will be included in a future version."; "privacy_policy_title" = "Privacy Policy"; @@ -311,6 +312,7 @@ "choose_plan_title" = "Choose your plan"; "tasks_title" = "Tasks"; "queued_sync_tasks_title" = "Queued sync tasks (%d)"; +"sync_tasks_queued_count_title" = "Queued Tasks (%d)"; "sync_tasks_alert_description" = "If there's a synchronization task that is stuck and doesn't clear out after having an active internet connection, you can log out and sign in to clear out the current tasks. This will revert your library to the last state synchronized to our servers. Keep in mind that upload tasks can take some time, and the progress will be periodically posted on the previous screen. @@ -402,8 +404,6 @@ We're working hard on providing a seamless experience, if possible, please conta "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Donate"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Download Folder"; -"download_folder_confirmation_message" = "Download all %d files from this folder?"; "hardcover_searching_books" = "Searching books..."; "hardcover_unlink_button" = "Unlink"; "hardcover_auto_match_books" = "Auto-match books"; @@ -448,6 +448,11 @@ We're working hard on providing a seamless experience, if possible, please conta "hours" = "Hours"; "minutes" = "Minutes"; +"provider_title" = "Provider"; +"provider_id_title" = "Item ID"; +"host_title" = "Host"; +"external_resources_title" = "External Resources"; + // Passkey Authentication "passkey_unnamed_device" = "Unnamed Device"; "passkey_delete_title" = "Delete Passkey"; @@ -506,3 +511,67 @@ We're working hard on providing a seamless experience, if possible, please conta "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Concurrent Tasks (%d)"; +"sync_tasks_running_empty_title" = "No running tasks"; +"sync_tasks_empty_title" = "No queued tasks"; +"sync_tasks_empty_description" = "All your sync tasks are up to date."; +"sync_task_update_progress_label" = "Updating progress for %@"; +"sync_task_upload_file_label" = "Uploading file"; +"audiobook_details_narrator_label" = "Narrated by %@"; +"stream_button" = "Stream"; +"passkey_title" = "Passkey"; +"create_profile_title" = "Create your profile"; +"create_profile_description" = "To enable cloud sync, sign into your profile. You can always do this later from within the Profile tab"; +"not_now_button" = "Not now"; +"quick_action_label" = "Quick Action %d"; +"sort_by_title" = "Sort by"; +"sort_default_option" = "Default"; +"sort_name_option" = "Name"; +"layout_options_title" = "Layout options"; +"layout_grid_option" = "Grid"; +"layout_list_option" = "List"; +"authors_title" = "Authors"; +"import_no_audio_files_alert" = "Your server hasn't reported the audio details for these items yet. Try again once it has finished scanning them."; +"import_title" = "Import"; +"files_title" = "%d Files"; +"file_path_title" = "File Path"; +"genres_title" = "Genres"; +"overview_title" = "Overview"; +"tags_title" = "Tags"; +"series_title" = "Series"; +"task_uploading_file_label" = "Uploading file"; +"task_updating_progress_label" = "Updating progress for %@"; +"yes_button" = "Yes"; +"ignore_button" = "Ignore"; +"resume_playback_alert_title" = "Resume Playback"; +"resume_playback_alert_message" = "There is another source with current play time %@. Play from this time?"; +"queue_library_sync_title" = "Library Sync"; +"queue_file_uploads_title" = "File Uploads"; +"close_title" = "Close"; +"external_sync_benefit_stream_title" = "Stream & Save Space"; +"external_sync_benefit_stream_subtitle" = "Listen instantly by streaming directly from your source. No need to download files or use up local storage."; +"external_sync_benefit_sync_title" = "Two-Way Progress Sync"; +"external_sync_benefit_sync_subtitle" = "Your position syncs both ways with Jellyfin or Audiobookshelf, and through your BookPlayer account so every device you use stays in step."; +"external_sync_disclaimer_description" = "- Your audiobook files stay on your own server, never on ours. Your subscription unlocks streaming and covers the cost of syncing your progress between devices through our servers."; +"external_sync_info_title" = "Stream & Sync"; +"queued_tasks_title" = "Queued Tasks"; +"info_title" = "Info"; +"integration_error_missing_connection" = "This book streams from your media server. Connect it in Settings to download or play it."; +"carplay_playback_failed_message" = "Can't play this book. Open BookPlayer on your phone for details."; +"carplay_missing_connection_message" = "This book streams from a media server that isn't set up on this device. Open BookPlayer on your phone to connect it."; +"sync_paused_lane_title" = "Paused: a task needs your attention"; +"sync_task_retry_button" = "Retry"; +"sync_task_report_button" = "Report"; +"sync_task_paused_voiceover" = "Paused: %@"; +"sync_paused_alert_title" = "Sync is paused until a queued task is resolved"; +"upload_file_too_large_message" = "Larger than 10 GB, so it stays on this device and isn't backed up."; +"sync_task_dismiss_button" = "Dismiss"; +"sync_tasks_need_attention_voiceover" = "Needs attention"; +"continued_upload_title" = "Uploading files"; +"continued_upload_subtitle" = "(%1$d / %2$d) %3$@"; +"continue_in_background_button" = "Continue in background"; +"continue_uploads_background_button" = "Continue uploads in background"; +"uploads_starting_title" = "Starting…"; +"uploads_running_background_title" = "Uploading in background"; +"uploads_running_background_caption" = "Uploading in background · %d%%"; +"continue_uploads_short_button" = "Continue"; diff --git a/BookPlayer/Coordinators/MainCoordinator.swift b/BookPlayer/Coordinators/MainCoordinator.swift index ea0cfa4eb..f202dcbbc 100644 --- a/BookPlayer/Coordinators/MainCoordinator.swift +++ b/BookPlayer/Coordinators/MainCoordinator.swift @@ -29,6 +29,7 @@ class MainCoordinator: NSObject { let watchConnectivityService: PhoneWatchConnectivityService let jellyfinConnectionService: JellyfinConnectionService let audiobookshelfConnectionService: AudiobookShelfConnectionService + let syncQueueService: SyncQueueService let hardcoverService: HardcoverService let preferencesService: PreferencesSyncService @@ -56,8 +57,10 @@ class MainCoordinator: NSObject { playerManager: playerManager, syncService: syncService, playerLoaderService: coreServices.playerLoaderService, - preferencesService: coreServices.preferencesService + preferencesService: coreServices.preferencesService, + externalProgressService: coreServices.externalProgressService ) + self.syncQueueService = coreServices.syncQueueService self.singleFileDownloadService = SingleFileDownloadService(networkClient: NetworkClient()) self.watchConnectivityService = coreServices.watchService let jellyfinService = JellyfinConnectionService() @@ -108,6 +111,8 @@ class MainCoordinator: NSObject { .environment(\.playerLoaderService, playerLoaderService) .environment(\.playbackService, playbackService) .environment(\.preferencesService, preferencesService) + .environment(\.syncQueueService, syncQueueService) + .environment(\.uploadContinuation, AppServices.shared.uploadContinuation) ) vc.modalPresentationStyle = .fullScreen vc.modalTransitionStyle = .crossDissolve diff --git a/BookPlayer/Generated/AutoMockable.generated.swift b/BookPlayer/Generated/AutoMockable.generated.swift index 05c1ccd62..7c018dcb4 100644 --- a/BookPlayer/Generated/AutoMockable.generated.swift +++ b/BookPlayer/Generated/AutoMockable.generated.swift @@ -318,6 +318,26 @@ class LibraryServiceProtocolMock: LibraryServiceProtocol { return getSimpleItemWithReturnValue } } + //MARK: - getSimpleItem + + var getSimpleItemForCallsCount = 0 + var getSimpleItemForCalled: Bool { + return getSimpleItemForCallsCount > 0 + } + var getSimpleItemForReceivedUuid: String? + var getSimpleItemForReceivedInvocations: [String] = [] + var getSimpleItemForReturnValue: SimpleLibraryItem? + var getSimpleItemForClosure: ((String) -> SimpleLibraryItem?)? + func getSimpleItem(for uuid: String) -> SimpleLibraryItem? { + getSimpleItemForCallsCount += 1 + getSimpleItemForReceivedUuid = uuid + getSimpleItemForReceivedInvocations.append(uuid) + if let getSimpleItemForClosure = getSimpleItemForClosure { + return getSimpleItemForClosure(uuid) + } else { + return getSimpleItemForReturnValue + } + } //MARK: - getItems var getItemsNotInParentFolderCallsCount = 0 @@ -493,6 +513,21 @@ class LibraryServiceProtocolMock: LibraryServiceProtocol { loadChaptersIfNeededRelativePathAssetReceivedInvocations.append((relativePath: relativePath, asset: asset)) await loadChaptersIfNeededRelativePathAssetClosure?(relativePath, asset) } + //MARK: - storeChaptersIfNeeded + + var storeChaptersIfNeededRelativePathChaptersCallsCount = 0 + var storeChaptersIfNeededRelativePathChaptersCalled: Bool { + return storeChaptersIfNeededRelativePathChaptersCallsCount > 0 + } + var storeChaptersIfNeededRelativePathChaptersReceivedArguments: (relativePath: String, chapters: [ChapterMetadata])? + var storeChaptersIfNeededRelativePathChaptersReceivedInvocations: [(relativePath: String, chapters: [ChapterMetadata])] = [] + var storeChaptersIfNeededRelativePathChaptersClosure: ((String, [ChapterMetadata]) async -> Void)? + func storeChaptersIfNeeded(relativePath: String, chapters: [ChapterMetadata]) async { + storeChaptersIfNeededRelativePathChaptersCallsCount += 1 + storeChaptersIfNeededRelativePathChaptersReceivedArguments = (relativePath: relativePath, chapters: chapters) + storeChaptersIfNeededRelativePathChaptersReceivedInvocations.append((relativePath: relativePath, chapters: chapters)) + await storeChaptersIfNeededRelativePathChaptersClosure?(relativePath, chapters) + } //MARK: - reloadChapters var reloadChaptersRelativePathCallsCount = 0 @@ -997,6 +1032,163 @@ class LibraryServiceProtocolMock: LibraryServiceProtocol { return getHardcoverBookForReturnValue } } + //MARK: - setExternalResource + + var setExternalResourceProviderNameProviderIdForCallsCount = 0 + var setExternalResourceProviderNameProviderIdForCalled: Bool { + return setExternalResourceProviderNameProviderIdForCallsCount > 0 + } + var setExternalResourceProviderNameProviderIdForReceivedArguments: (providerName: String, providerId: String, uuid: String)? + var setExternalResourceProviderNameProviderIdForReceivedInvocations: [(providerName: String, providerId: String, uuid: String)] = [] + var setExternalResourceProviderNameProviderIdForReturnValue: SyncableExternalResource? + var setExternalResourceProviderNameProviderIdForClosure: ((String, String, String) async -> SyncableExternalResource?)? + func setExternalResource(providerName: String, providerId: String, for uuid: String) async -> SyncableExternalResource? { + setExternalResourceProviderNameProviderIdForCallsCount += 1 + setExternalResourceProviderNameProviderIdForReceivedArguments = (providerName: providerName, providerId: providerId, uuid: uuid) + setExternalResourceProviderNameProviderIdForReceivedInvocations.append((providerName: providerName, providerId: providerId, uuid: uuid)) + if let setExternalResourceProviderNameProviderIdForClosure = setExternalResourceProviderNameProviderIdForClosure { + return await setExternalResourceProviderNameProviderIdForClosure(providerName, providerId, uuid) + } else { + return setExternalResourceProviderNameProviderIdForReturnValue + } + } + //MARK: - removeExternalResource + + var removeExternalResourceProviderNameForCallsCount = 0 + var removeExternalResourceProviderNameForCalled: Bool { + return removeExternalResourceProviderNameForCallsCount > 0 + } + var removeExternalResourceProviderNameForReceivedArguments: (providerName: String, uuid: String)? + var removeExternalResourceProviderNameForReceivedInvocations: [(providerName: String, uuid: String)] = [] + var removeExternalResourceProviderNameForReturnValue: String? + var removeExternalResourceProviderNameForClosure: ((String, String) async -> String?)? + func removeExternalResource(providerName: String, for uuid: String) async -> String? { + removeExternalResourceProviderNameForCallsCount += 1 + removeExternalResourceProviderNameForReceivedArguments = (providerName: providerName, uuid: uuid) + removeExternalResourceProviderNameForReceivedInvocations.append((providerName: providerName, uuid: uuid)) + if let removeExternalResourceProviderNameForClosure = removeExternalResourceProviderNameForClosure { + return await removeExternalResourceProviderNameForClosure(providerName, uuid) + } else { + return removeExternalResourceProviderNameForReturnValue + } + } + //MARK: - getExternalResources + + var getExternalResourcesForCallsCount = 0 + var getExternalResourcesForCalled: Bool { + return getExternalResourcesForCallsCount > 0 + } + var getExternalResourcesForReceivedRelativePath: String? + var getExternalResourcesForReceivedInvocations: [String] = [] + var getExternalResourcesForReturnValue: [SimpleExternalResource]! + var getExternalResourcesForClosure: ((String) async -> [SimpleExternalResource])? + func getExternalResources(for relativePath: String) async -> [SimpleExternalResource] { + getExternalResourcesForCallsCount += 1 + getExternalResourcesForReceivedRelativePath = relativePath + getExternalResourcesForReceivedInvocations.append(relativePath) + if let getExternalResourcesForClosure = getExternalResourcesForClosure { + return await getExternalResourcesForClosure(relativePath) + } else { + return getExternalResourcesForReturnValue + } + } + //MARK: - findResource + + var findResourceForProviderNameCallsCount = 0 + var findResourceForProviderNameCalled: Bool { + return findResourceForProviderNameCallsCount > 0 + } + var findResourceForProviderNameReceivedArguments: (providerId: String, providerName: String?)? + var findResourceForProviderNameReceivedInvocations: [(providerId: String, providerName: String?)] = [] + var findResourceForProviderNameReturnValue: SimpleExternalResource? + var findResourceForProviderNameClosure: ((String, String?) -> SimpleExternalResource?)? + func findResource(for providerId: String, providerName: String?) -> SimpleExternalResource? { + findResourceForProviderNameCallsCount += 1 + findResourceForProviderNameReceivedArguments = (providerId: providerId, providerName: providerName) + findResourceForProviderNameReceivedInvocations.append((providerId: providerId, providerName: providerName)) + if let findResourceForProviderNameClosure = findResourceForProviderNameClosure { + return findResourceForProviderNameClosure(providerId, providerName) + } else { + return findResourceForProviderNameReturnValue + } + } + //MARK: - findResources + + var findResourcesForCallsCount = 0 + var findResourcesForCalled: Bool { + return findResourcesForCallsCount > 0 + } + var findResourcesForReceivedUuid: String? + var findResourcesForReceivedInvocations: [String] = [] + var findResourcesForReturnValue: [SimpleExternalResource]? + var findResourcesForClosure: ((String) -> [SimpleExternalResource]?)? + func findResources(for uuid: String) -> [SimpleExternalResource]? { + findResourcesForCallsCount += 1 + findResourcesForReceivedUuid = uuid + findResourcesForReceivedInvocations.append(uuid) + if let findResourcesForClosure = findResourcesForClosure { + return findResourcesForClosure(uuid) + } else { + return findResourcesForReturnValue + } + } + //MARK: - findMediaServerResources + + var findMediaServerResourcesAtCallsCount = 0 + var findMediaServerResourcesAtCalled: Bool { + return findMediaServerResourcesAtCallsCount > 0 + } + var findMediaServerResourcesAtReceivedRelativePath: String? + var findMediaServerResourcesAtReceivedInvocations: [String?] = [] + var findMediaServerResourcesAtReturnValue: [SimpleExternalResource]! + var findMediaServerResourcesAtClosure: ((String?) async -> [SimpleExternalResource])? + func findMediaServerResources(at relativePath: String?) async -> [SimpleExternalResource] { + findMediaServerResourcesAtCallsCount += 1 + findMediaServerResourcesAtReceivedRelativePath = relativePath + findMediaServerResourcesAtReceivedInvocations.append(relativePath) + if let findMediaServerResourcesAtClosure = findMediaServerResourcesAtClosure { + return await findMediaServerResourcesAtClosure(relativePath) + } else { + return findMediaServerResourcesAtReturnValue + } + } + //MARK: - insertItems + + var insertItemsFromResourcesCallsCount = 0 + var insertItemsFromResourcesCalled: Bool { + return insertItemsFromResourcesCallsCount > 0 + } + var insertItemsFromResourcesReceivedResources: [SimpleExternalResource]? + var insertItemsFromResourcesReceivedInvocations: [[SimpleExternalResource]] = [] + var insertItemsFromResourcesReturnValue: [SimpleLibraryItem]! + var insertItemsFromResourcesClosure: (([SimpleExternalResource]) async -> [SimpleLibraryItem])? + @MainActor + func insertItems(fromResources resources: [SimpleExternalResource]) async -> [SimpleLibraryItem] { + insertItemsFromResourcesCallsCount += 1 + insertItemsFromResourcesReceivedResources = resources + insertItemsFromResourcesReceivedInvocations.append(resources) + if let insertItemsFromResourcesClosure = insertItemsFromResourcesClosure { + return await insertItemsFromResourcesClosure(resources) + } else { + return insertItemsFromResourcesReturnValue + } + } + //MARK: - handleSyncFromExternalResource + + var handleSyncFromExternalResourceProviderNameSnapshotsByProviderIdCallsCount = 0 + var handleSyncFromExternalResourceProviderNameSnapshotsByProviderIdCalled: Bool { + return handleSyncFromExternalResourceProviderNameSnapshotsByProviderIdCallsCount > 0 + } + var handleSyncFromExternalResourceProviderNameSnapshotsByProviderIdReceivedArguments: (providerName: String, snapshotsByProviderId: [String: ExternalItemSnapshot])? + var handleSyncFromExternalResourceProviderNameSnapshotsByProviderIdReceivedInvocations: [(providerName: String, snapshotsByProviderId: [String: ExternalItemSnapshot])] = [] + var handleSyncFromExternalResourceProviderNameSnapshotsByProviderIdClosure: ((String, [String: ExternalItemSnapshot]) -> Void)? + @MainActor + func handleSyncFromExternalResource(providerName: String, snapshotsByProviderId: [String: ExternalItemSnapshot]) { + handleSyncFromExternalResourceProviderNameSnapshotsByProviderIdCallsCount += 1 + handleSyncFromExternalResourceProviderNameSnapshotsByProviderIdReceivedArguments = (providerName: providerName, snapshotsByProviderId: snapshotsByProviderId) + handleSyncFromExternalResourceProviderNameSnapshotsByProviderIdReceivedInvocations.append((providerName: providerName, snapshotsByProviderId: snapshotsByProviderId)) + handleSyncFromExternalResourceProviderNameSnapshotsByProviderIdClosure?(providerName, snapshotsByProviderId) + } } class PlaybackServiceProtocolMock: PlaybackServiceProtocol { //MARK: - updatePlaybackTime @@ -1684,6 +1876,11 @@ class SyncServiceProtocolMock: SyncServiceProtocol { set(value) { underlyingDownloadErrorPublisher = value } } var underlyingDownloadErrorPublisher: PassthroughSubject<(String, Error), Never>! + var hasRunFirstSync: Bool { + get { return underlyingHasRunFirstSync } + set(value) { underlyingHasRunFirstSync = value } + } + var underlyingHasRunFirstSync: Bool! //MARK: - updateSyncEnabled var updateSyncEnabledCallsCount = 0 @@ -1726,22 +1923,6 @@ class SyncServiceProtocolMock: SyncServiceProtocol { return queuedJobsCountReturnValue } } - //MARK: - observeTasksCount - - var observeTasksCountCallsCount = 0 - var observeTasksCountCalled: Bool { - return observeTasksCountCallsCount > 0 - } - var observeTasksCountReturnValue: AnyPublisher! - var observeTasksCountClosure: (() -> AnyPublisher)? - func observeTasksCount() -> AnyPublisher { - observeTasksCountCallsCount += 1 - if let observeTasksCountClosure = observeTasksCountClosure { - return observeTasksCountClosure() - } else { - return observeTasksCountReturnValue - } - } //MARK: - canSyncListContents var canSyncListContentsAtIgnoreLastTimestampCallsCount = 0 @@ -1820,25 +2001,16 @@ class SyncServiceProtocolMock: SyncServiceProtocol { return syncBookmarksListRelativePathReturnValue } } - //MARK: - fetchSyncedIdentifiers + //MARK: - scheduleMissingItemsIfNeeded - var fetchSyncedIdentifiersThrowableError: Error? - var fetchSyncedIdentifiersCallsCount = 0 - var fetchSyncedIdentifiersCalled: Bool { - return fetchSyncedIdentifiersCallsCount > 0 + var scheduleMissingItemsIfNeededCallsCount = 0 + var scheduleMissingItemsIfNeededCalled: Bool { + return scheduleMissingItemsIfNeededCallsCount > 0 } - var fetchSyncedIdentifiersReturnValue: [String]! - var fetchSyncedIdentifiersClosure: (() async throws -> [String])? - func fetchSyncedIdentifiers() async throws -> [String] { - if let error = fetchSyncedIdentifiersThrowableError { - throw error - } - fetchSyncedIdentifiersCallsCount += 1 - if let fetchSyncedIdentifiersClosure = fetchSyncedIdentifiersClosure { - return try await fetchSyncedIdentifiersClosure() - } else { - return fetchSyncedIdentifiersReturnValue - } + var scheduleMissingItemsIfNeededClosure: (() async -> Void)? + func scheduleMissingItemsIfNeeded() async { + scheduleMissingItemsIfNeededCallsCount += 1 + await scheduleMissingItemsIfNeededClosure?() } //MARK: - getRemoteFileURLs @@ -1988,21 +2160,35 @@ class SyncServiceProtocolMock: SyncServiceProtocol { scheduleUploadArtworkRelativePathUuidReceivedInvocations.append((relativePath: relativePath, uuid: uuid)) scheduleUploadArtworkRelativePathUuidClosure?(relativePath, uuid) } - //MARK: - getAllQueuedJobs + //MARK: - scheduleExternalResourceUpload - var getAllQueuedJobsCallsCount = 0 - var getAllQueuedJobsCalled: Bool { - return getAllQueuedJobsCallsCount > 0 + var scheduleExternalResourceUploadRelativePathUuidCallsCount = 0 + var scheduleExternalResourceUploadRelativePathUuidCalled: Bool { + return scheduleExternalResourceUploadRelativePathUuidCallsCount > 0 } - var getAllQueuedJobsReturnValue: [SyncTaskReference]! - var getAllQueuedJobsClosure: (() async -> [SyncTaskReference])? - func getAllQueuedJobs() async -> [SyncTaskReference] { - getAllQueuedJobsCallsCount += 1 - if let getAllQueuedJobsClosure = getAllQueuedJobsClosure { - return await getAllQueuedJobsClosure() - } else { - return getAllQueuedJobsReturnValue - } + var scheduleExternalResourceUploadRelativePathUuidReceivedArguments: (resource: SyncableExternalResource, relativePath: String, uuid: String)? + var scheduleExternalResourceUploadRelativePathUuidReceivedInvocations: [(resource: SyncableExternalResource, relativePath: String, uuid: String)] = [] + var scheduleExternalResourceUploadRelativePathUuidClosure: ((SyncableExternalResource, String, String) -> Void)? + func scheduleExternalResourceUpload(_ resource: SyncableExternalResource, relativePath: String, uuid: String) { + scheduleExternalResourceUploadRelativePathUuidCallsCount += 1 + scheduleExternalResourceUploadRelativePathUuidReceivedArguments = (resource: resource, relativePath: relativePath, uuid: uuid) + scheduleExternalResourceUploadRelativePathUuidReceivedInvocations.append((resource: resource, relativePath: relativePath, uuid: uuid)) + scheduleExternalResourceUploadRelativePathUuidClosure?(resource, relativePath, uuid) + } + //MARK: - scheduleExternalResourceDeletion + + var scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidCallsCount = 0 + var scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidCalled: Bool { + return scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidCallsCount > 0 + } + var scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidReceivedArguments: (providerName: String, providerId: String, relativePath: String, uuid: String)? + var scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidReceivedInvocations: [(providerName: String, providerId: String, relativePath: String, uuid: String)] = [] + var scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidClosure: ((String, String, String, String) -> Void)? + func scheduleExternalResourceDeletion(providerName: String, providerId: String, relativePath: String, uuid: String) { + scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidCallsCount += 1 + scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidReceivedArguments = (providerName: providerName, providerId: providerId, relativePath: relativePath, uuid: uuid) + scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidReceivedInvocations.append((providerName: providerName, providerId: providerId, relativePath: relativePath, uuid: uuid)) + scheduleExternalResourceDeletionProviderNameProviderIdRelativePathUuidClosure?(providerName, providerId, relativePath, uuid) } //MARK: - getAllQueuedJobsWithParams @@ -2047,6 +2233,17 @@ class SyncServiceProtocolMock: SyncServiceProtocol { cancelAllJobsCallsCount += 1 cancelAllJobsClosure?() } + //MARK: - settleDownloads + + var settleDownloadsCallsCount = 0 + var settleDownloadsCalled: Bool { + return settleDownloadsCallsCount > 0 + } + var settleDownloadsClosure: (() async -> Void)? + func settleDownloads() async { + settleDownloadsCallsCount += 1 + await settleDownloadsClosure?() + } //MARK: - resetAllJobs var resetAllJobsCallsCount = 0 diff --git a/BookPlayer/Hardcover/ItemDetails Section/ItemDetailsHardcoverSectionView.swift b/BookPlayer/Hardcover/ItemDetails Section/ItemDetailsHardcoverSectionView.swift index dac44940e..86db3b59d 100644 --- a/BookPlayer/Hardcover/ItemDetails Section/ItemDetailsHardcoverSectionView.swift +++ b/BookPlayer/Hardcover/ItemDetails Section/ItemDetailsHardcoverSectionView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import BookPlayerKit struct ItemDetailsHardcoverSectionView: View { @Environment(\.dismiss) var dismiss @@ -16,19 +17,21 @@ struct ItemDetailsHardcoverSectionView: View { var body: some View { Section( - header: Text("section_item_hardcover".localized) - .foregroundStyle(theme.secondaryColor) + header: HStack(spacing: Spacing.S) { + Text("section_item_hardcover") + .foregroundStyle(theme.secondaryColor) + if viewModel.isFetchingBook { + ProgressView() + .controlSize(.small) + } + } ) { NavigationLink( destination: { HardcoverBookPickerView(viewModel: viewModel.pickerViewModel) }, label: { - if let row = viewModel.pickerViewModel.selected { - HardcoverBookRow(viewModel: row) - } else { - Text("select_title".localized) - } + HardcoverSelectionLabel(pickerViewModel: viewModel.pickerViewModel) } ) .accessibilityHint("voiceover_hardcover_navigation_hint".localized) @@ -37,9 +40,25 @@ struct ItemDetailsHardcoverSectionView: View { } } +/// Observes the picker view model directly so the row reflects `selected` as soon as it's +/// set — the section's own `@ObservedObject` won't fire for changes on the nested model. +private struct HardcoverSelectionLabel: View { + @ObservedObject var pickerViewModel: HardcoverBookPickerView.Model + + var body: some View { + if let row = pickerViewModel.selected { + HardcoverBookRow(viewModel: row) + } else { + Text("select_title") + } + } +} + extension ItemDetailsHardcoverSectionView { class Model: ObservableObject { @Published var pickerViewModel: HardcoverBookPickerView.Model + /// True while fetching the linked book's info from Hardcover via its external resource + @Published var isFetchingBook: Bool = false init(pickerViewModel: HardcoverBookPickerView.Model = .init()) { self.pickerViewModel = pickerViewModel diff --git a/BookPlayer/Hardcover/Network/HardcoverService.swift b/BookPlayer/Hardcover/Network/HardcoverService.swift index 906f2b6b8..a466144f8 100644 --- a/BookPlayer/Hardcover/Network/HardcoverService.swift +++ b/BookPlayer/Hardcover/Network/HardcoverService.swift @@ -42,6 +42,11 @@ protocol HardcoverServiceProtocol { /// Remove a book from the user's Hardcover library /// - Parameter book: The Hardcover book to remove from library func removeFromLibrary(_ book: SimpleHardcoverBook) async throws + + /// Fetch a single book's info from Hardcover by its id + /// - Parameter id: The Hardcover book id (the external resource's providerId) + /// - Returns: The book info, or nil if not found + func getBook(id: Int) async throws -> SimpleHardcoverBook? } @Observable @@ -50,6 +55,11 @@ final class HardcoverService: BPLogger, HardcoverServiceProtocol { private let graphQL = GraphQLClient(baseURL: "https://api.hardcover.app/v1/graphql") private var audioMetadataService: AudioMetadataServiceProtocol! private var libraryService: LibraryServiceProtocol! + /// IUO like its siblings (two-step init()+setup() DI): always injected by + /// AppServices in production. Deliberately NOT optional — the schedule calls below + /// are the sync-up for Hardcover-driven changes, and optional chaining would turn a + /// missed injection into silently un-synced changes instead of a loud failure. + private var syncService: SyncServiceProtocol! private var metadataSubscription: AnyCancellable? private var progressSubscription: AnyCancellable? @@ -71,10 +81,12 @@ final class HardcoverService: BPLogger, HardcoverServiceProtocol { func setup( libraryService: LibraryServiceProtocol, + syncService: SyncServiceProtocol, keychain: KeychainServiceProtocol = KeychainService(), audioMetadataService: AudioMetadataServiceProtocol = AudioMetadataService() ) { self.libraryService = libraryService + self.syncService = syncService self.keychain = keychain self.audioMetadataService = audioMetadataService @@ -132,6 +144,38 @@ extension HardcoverService { return result } + func getBook(id: Int) async throws -> SimpleHardcoverBook? { + Self.logger.info("Fetching Hardcover book info for id \(id)") + + let queryString = """ + query GetBook($id: Int!) { + books(where: {id: {_eq: $id}}, limit: 1) { + id + title + image { url } + contributions { author { name } } + } + } + """ + + let result = try await graphQL.execute( + query: queryString, + variables: ["id": id], + authorization: authorization, + responseType: BookByIdData.self + ) + + guard let book = result.books.first else { return nil } + + return SimpleHardcoverBook( + id: book.id, + artworkURL: book.image?.url.flatMap(URL.init(string:)), + title: book.title, + author: book.contributions?.first?.author?.name ?? "", + status: .local + ) + } + private func insertUserBook(bookID: Int, status: HardcoverBook.Status) async throws -> InsertUserBookData { let mutation = """ mutation InsertUserBook($book_id: Int!, $status_id: Int!) { @@ -231,47 +275,63 @@ extension HardcoverService { } private func handleBookStarted(relativePath: String, percentCompleted: Double) async { - guard - percentCompleted > readingThreshold, - var item = await libraryService.getHardcoverBook(for: relativePath), - item.status < .reading - else { return } + guard percentCompleted > readingThreshold else { return } - do { - Self.logger.info("Updating Hardcover API: book \(item.id) to 'reading' status") - _ = try await insertUserBook( - bookID: item.id, - status: .reading - ) - Self.logger.info("Successfully updated Hardcover API for book \(item.id)") + await updateExternalResources(for: relativePath, to: .reading) + } - item.status = .reading - await libraryService.setHardcoverBook(item, for: relativePath) - Self.logger.info("Updated local status to 'reading' for \(relativePath)") - } catch { - Self.logger.error("Failed to update Hardcover status for book \(item.id): \(error)") + private func handleBookFinished(relativePath: String) async { + await updateExternalResources(for: relativePath, to: .read) + } + + /// Push the new status to the item's Hardcover link(s), if any — reading-status + /// actions only exist for Hardcover; other providers' progress rides the + /// externalUpdate queue instead. + private func updateExternalResources( + for relativePath: String, + to status: HardcoverBook.Status + ) async { + let resources = await libraryService.getExternalResources(for: relativePath) + + for resource in resources + where ExternalResource.ProviderName(rawValue: resource.providerName) == .hardcover { + await updateHardcoverStatus(status, providerId: resource.providerId, for: relativePath) } } - private func handleBookFinished(relativePath: String) async { - guard - var item = await libraryService.getHardcoverBook(for: relativePath), - item.status != .read - else { return } + /// Push the given status to Hardcover for the book identified by `providerId`, + /// keeping the local reference in sync. No-op if it already reached `status`. + private func updateHardcoverStatus( + _ status: HardcoverBook.Status, + providerId: String, + for relativePath: String + ) async { + guard let bookID = Int(providerId) else { return } + + let existing = await libraryService.getHardcoverBook(for: relativePath) + if let existing, existing.status >= status { + return + } do { - Self.logger.info("Updating Hardcover API: book \(item.id) to 'read' status") - _ = try await insertUserBook( - bookID: item.id, - status: .read + Self.logger.info("Updating Hardcover API: book \(bookID) to '\(String(describing: status))' status") + let response = try await insertUserBook(bookID: bookID, status: status) + + var updated = existing ?? SimpleHardcoverBook( + id: bookID, + artworkURL: nil, + title: "", + author: "", + status: status ) - Self.logger.info("Successfully updated Hardcover API for book \(item.id)") - - item.status = .read - await libraryService.setHardcoverBook(item, for: relativePath) - Self.logger.info("Updated local status to 'read' for \(relativePath)") + updated.status = status + if updated.userBookID == nil { + updated.userBookID = response.insertUserBook.id + } + await libraryService.setHardcoverBook(updated, for: relativePath) + Self.logger.info("Updated local Hardcover status to '\(String(describing: status))' for \(relativePath)") } catch { - Self.logger.error("Failed to update Hardcover status for book \(item.id): \(error)") + Self.logger.error("Failed to update Hardcover status for book \(bookID): \(error)") } } @@ -325,6 +385,8 @@ extension HardcoverService { } await libraryService.setHardcoverBook(book, for: match.item.relativePath) + await uploadExternalResource(for: book, to: match.item) + await downloadArtworkIfNeeded(for: book, to: match.item) Self.logger.info("Auto-matched '\(match.item.title)' to Hardcover ID \(bookID)") processedCount += 1 } catch { @@ -343,6 +405,9 @@ extension HardcoverService { _ book: SimpleHardcoverBook?, to item: SimpleLibraryItem ) async { + /// Remove any existing hardcover resource first (handles both unlink and swap) + await deleteExternalResource(for: item) + guard let book else { await libraryService.setHardcoverBook(nil, for: item.relativePath) Self.logger.info("Removed Hardcover assignment from '\(item.title)'") @@ -351,25 +416,102 @@ extension HardcoverService { Self.logger.info("Assigning Hardcover book \(book.id) to '\(item.title)'") - guard autoAddWantToReadEnabled, authorization != nil else { + if autoAddWantToReadEnabled, authorization != nil { + do { + let response = try await insertUserBook( + bookID: book.id, + status: .library + ) + Self.logger.info("Added '\(item.title)' to Hardcover Want to Read list") + + var updated = book + updated.status = .library + updated.userBookID = response.insertUserBook.id + await libraryService.setHardcoverBook(updated, for: item.relativePath) + } catch { + Self.logger.error("Failed to add '\(item.title)' to Hardcover Want to Read: \(error)") + await libraryService.setHardcoverBook(book, for: item.relativePath) + } + } else { await libraryService.setHardcoverBook(book, for: item.relativePath) - return } - do { - let response = try await insertUserBook( - bookID: book.id, - status: .library + await uploadExternalResource(for: book, to: item) + await downloadArtworkIfNeeded(for: book, to: item) + } + + /// Create the hardcover external resource for the item and sync-upload it if a sync service is available. + private func uploadExternalResource( + for book: SimpleHardcoverBook, + to item: SimpleLibraryItem + ) async { + guard + let syncable = await libraryService.setExternalResource( + providerName: ExternalResource.ProviderName.hardcover.rawValue, + providerId: String(book.id), + for: item.uuid ) - Self.logger.info("Added '\(item.title)' to Hardcover Want to Read list") + else { return } + + syncService.scheduleExternalResourceUpload( + syncable, + relativePath: item.relativePath, + uuid: item.uuid + ) + } + + /// Delete the hardcover external resource for the item and schedule a delete sync task if a sync service is available. + private func deleteExternalResource(for item: SimpleLibraryItem) async { + let providerName = ExternalResource.ProviderName.hardcover.rawValue - var updated = book - updated.status = .library - updated.userBookID = response.insertUserBook.id - await libraryService.setHardcoverBook(updated, for: item.relativePath) + guard + let providerId = await libraryService.removeExternalResource( + providerName: providerName, + for: item.uuid + ) + else { return } + + syncService.scheduleExternalResourceDeletion( + providerName: providerName, + providerId: providerId, + relativePath: item.relativePath, + uuid: item.uuid + ) + } + + /// If the Hardcover book has artwork and the item has none, download it, set it as the + /// item's artwork, and run the artwork-upload process (which itself no-ops unless cloud + /// sync is active, i.e. the user is Pro/Lite). + private func downloadArtworkIfNeeded( + for book: SimpleHardcoverBook, + to item: SimpleLibraryItem + ) async { + guard + let artworkURL = book.artworkURL, + item.artworkURL == nil + else { return } + + // item.artworkURL == nil can't see EMBEDDED artwork (it only tracks custom/remote), + // and the cache is populated lazily on first render — so a bare isCached check + // raced auto-match on import: a not-yet-rendered item's embedded art lost to the + // Hardcover cover PERMANENTLY (the cache key shadows the extractor forever) and + // scheduleUploadArtwork then propagated the wrong cover to every device. Run the + // canonical pipeline first: on a cache miss it extracts embedded artwork; only an + // item with genuinely no artwork of its own falls back to Hardcover's cover. + let hasOwnArtwork = await withCheckedContinuation { continuation in + ArtworkService.retrieveImageFromCache(for: item.relativePath) { result in + continuation.resume(returning: (try? result.get()) != nil) + } + } + guard !hasOwnArtwork else { return } + + do { + let (data, _) = try await URLSession.shared.data(from: artworkURL) + await ArtworkService.storeInCache(data, for: item.relativePath) + syncService.scheduleUploadArtwork(relativePath: item.relativePath, uuid: item.uuid) + Self.logger.info("Set Hardcover artwork for '\(item.title)'") } catch { - Self.logger.error("Failed to add '\(item.title)' to Hardcover Want to Read: \(error)") - await libraryService.setHardcoverBook(book, for: item.relativePath) + Self.logger.error("Failed to download Hardcover artwork for '\(item.title)': \(error)") } } @@ -455,3 +597,4 @@ extension SimpleHardcoverBook { ) } } + diff --git a/BookPlayer/Hardcover/Network/Models/BookByIdData.swift b/BookPlayer/Hardcover/Network/Models/BookByIdData.swift new file mode 100644 index 000000000..eac695eee --- /dev/null +++ b/BookPlayer/Hardcover/Network/Models/BookByIdData.swift @@ -0,0 +1,33 @@ +// +// BookByIdData.swift +// BookPlayer +// +// Created by Gianni Carlo on 5/9/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +/// Response model for fetching a single book by its Hardcover id. +struct BookByIdData: Codable { + let books: [Book] + + struct Book: Codable { + let id: Int + let title: String + let image: Artwork? + let contributions: [Contribution]? + + struct Artwork: Codable { + let url: String? + } + + struct Contribution: Codable { + let author: Author? + + struct Author: Codable { + let name: String? + } + } + } +} diff --git a/BookPlayer/Import/ExternalSource/ExternalImportEvents.swift b/BookPlayer/Import/ExternalSource/ExternalImportEvents.swift new file mode 100644 index 000000000..d59e18af5 --- /dev/null +++ b/BookPlayer/Import/ExternalSource/ExternalImportEvents.swift @@ -0,0 +1,30 @@ +// +// ExternalImportEvents.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import Combine +import Foundation + +/// The events of the external-import flow: integration screens send their CONFIRMED +/// virtual-import batches, `LibraryRootView` consumes and inserts. +/// +/// It carries no state — a PassthroughSubject is a wire, not a store — and conforms to +/// `ObservableObject` solely to ride `.environmentObject`'s LOUD injection contract: a +/// missed injection crashes at first read, instead of an `@Entry` throwaway default +/// silently splitting producers and consumer onto different subjects. +/// +/// Owned by `MainView` as a `@StateObject`, not built by `MainCoordinator`: it is a wire +/// between SwiftUI views that the coordinator never touches, and the media-servers sheet +/// the producers live in is attached to `MainView`, so injecting there reaches every party. +@MainActor +final class ExternalImportEvents: ObservableObject { + let confirmedBatches = PassthroughSubject<[SimpleExternalResource], Never>() + + func send(_ resources: [SimpleExternalResource]) { + confirmedBatches.send(resources) + } +} diff --git a/BookPlayer/Import/ExternalSource/ExternalImportView.swift b/BookPlayer/Import/ExternalSource/ExternalImportView.swift new file mode 100644 index 000000000..e0837a388 --- /dev/null +++ b/BookPlayer/Import/ExternalSource/ExternalImportView.swift @@ -0,0 +1,56 @@ +// +// ExternalImportView.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 17/3/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// +import SwiftUI +import BookPlayerKit + +/// The external (media-server) flow's confirmation sheet: owns the batch-seeded VM +/// and maps it onto the shared `ImportConfirmationView` shell — one flow's use of +/// that shell, not the shell itself. +struct ExternalImportView: View { + /// Owns the confirmation VM (initModel/@StateObject pattern): the VM is seeded + /// with the batch VALUE and survives re-renders of the presenting view. + @StateObject var viewModel: ExternalImportViewModel + + init(initModel: @escaping () -> ExternalImportViewModel) { + self._viewModel = .init(wrappedValue: initModel()) + } + @Environment(\.dismiss) var dismiss + + var body: some View { + ImportConfirmationView( + rows: viewModel.confirmationRows, + /// No description: `import_warning_description` warns that files are still + /// transferring and the count may change, and neither is true here — the batch is + /// frozen when the sheet opens and nothing is being copied. + onRemove: { viewModel.removeResource(withId: $0) }, + onConfirm: { + viewModel.confirm() + dismiss() + }, + onCancel: { + // Cancelling is just dismissing: `.sheet(item:)` nils the staged batch on its way + // out. The shell disables interactive dismissal, so this runs only from the X. + dismiss() + } + ) + } +} + +struct ExternalImportView_Previews: PreviewProvider { + static var previews: some View { + ExternalImportView( + initModel: { + ExternalImportViewModel( + batch: ExternalImportBatch(resources: []), + onConfirm: { _ in } + ) + } + ) + .environmentObject(ThemeViewModel()) + } +} diff --git a/BookPlayer/Import/ExternalSource/ExternalImportViewModel.swift b/BookPlayer/Import/ExternalSource/ExternalImportViewModel.swift new file mode 100644 index 000000000..9bbc820bf --- /dev/null +++ b/BookPlayer/Import/ExternalSource/ExternalImportViewModel.swift @@ -0,0 +1,51 @@ +// +// ExternalImportViewModel.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 17/3/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// +import Combine +import Foundation +import SwiftUI +import BookPlayerKit + +@MainActor +final class ExternalImportViewModel: ObservableObject { + /// OWNED value state, seeded from the producer's staged batch: removals republish + /// natively. (The previous shape was a computed passthrough onto a shared + /// ImportManager mailbox — mutations never fired this VM's objectWillChange, so the + /// delete button was visually dead.) + @Published private(set) var resources: [SimpleExternalResource] + private let onConfirm: ([SimpleExternalResource]) -> Void + + init( + batch: ExternalImportBatch, + onConfirm: @escaping ([SimpleExternalResource]) -> Void + ) { + self.resources = batch.resources + self.onConfirm = onConfirm + } + + /// The confirmation-shell rows for the current batch. Pure mapping, kept on the + /// VM (not in the view body) so the identity/filename/fallback rules are testable. + var confirmationRows: [ImportConfirmationRow] { + resources.map { resource in + ImportConfirmationRow( + id: resource.providerId, + icon: "waveform", + title: resource.libraryItem?.originalFileName ?? "voiceover_unknown_title".localized + ) + } + } + + func removeResource(withId id: String) { + resources.removeAll { $0.providerId == id } + } + + /// Hands the (possibly edited) selection back to the producing screen's VM, + /// which sends it on the import bus and owns any post-confirm navigation. + func confirm() { + onConfirm(resources) + } +} diff --git a/BookPlayer/Import/ImportConfirmationView.swift b/BookPlayer/Import/ImportConfirmationView.swift new file mode 100644 index 000000000..88bb1e5d1 --- /dev/null +++ b/BookPlayer/Import/ImportConfirmationView.swift @@ -0,0 +1,181 @@ +// +// ImportConfirmationView.swift +// BookPlayer +// +// Created by Gianni Carlo on 6/9/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import SwiftUI + +/// One removable row of an import confirmation list. Flow-agnostic: the external +/// flow renders waveform rows from its batch; the file-import flow (a future +/// adoption replacing ImportViewController) adds folder icons and "N files" +/// subtitles from its own view model. +struct ImportConfirmationRow: Identifiable { + let id: String + let icon: String + let title: String + var subtitle: String? +} + +/// The reusable import-confirmation shell: title, optional description, count, a +/// removable row list, and cancel/confirm actions. Deliberately presentation-agnostic — +/// dismissal, destructive-cancel semantics, and row sourcing belong to the flow +/// wrappers (see `ExternalImportView`). +/// +/// # Gaps for the `ImportViewController` retirement +/// +/// This shell is currently shaped for the external flow, whose needs are the simpler +/// half. Four things the UIKit screen does are NOT implemented here, and the file flow +/// cannot adopt it until they are. None of them are reachable today: an external batch +/// is frozen at confirmation and its removals cannot fail. +/// +/// 1. **Failure has nowhere to go.** `onRemove` and `onCancel` are non-throwing, because the +/// external flow only mutates an array and dismisses. Their file-flow equivalents both +/// throw — `ImportViewModel.deleteItem` and `discardImportOperation` — and +/// `ImportViewController` catches each and raises `error_title`. Adopting needs throwing +/// closures, or an error binding, or those failures vanish silently. `onConfirm` is fine +/// as it is: `createOperation()` does not throw. +/// 2. **Folder rows.** `ImportViewController` picks `folder` vs `waveform` per item and +/// fills a "N Files" subtitle for folders. `ImportConfirmationRow` already carries +/// `icon` and `subtitle` for this; only the external flow's mapping ignores them, +/// since media-server items are always single files. +/// 3. **Live counts.** `ImportViewModel.subscribeNewFolders` runs a `DirectoryWatcher` per +/// nested SUBDIRECTORY — not the dropped folder itself, so files landing in its root +/// never bump `subItems` — and raises the count as files arrive. That is what +/// `import_warning_description` warns about, so without it the copy is a promise the +/// screen cannot keep, which is why the external flow passes no description at all. +/// 4. **Total vs row count.** The UIKit header counts files INSIDE folders via +/// `getTotalItems()`; this counts rows. Identical until folders appear. +struct ImportConfirmationView: View { + let rows: [ImportConfirmationRow] + /// Nil when the flow has no caveat to give. Not defaulted on purpose: a default is how + /// the file flow's "transferring files may take a while" ended up on the external + /// screen, where nothing transfers and the batch cannot change size. + var description: LocalizedStringKey? + let onRemove: (ImportConfirmationRow.ID) -> Void + let onConfirm: () -> Void + let onCancel: () -> Void + + @EnvironmentObject private var theme: ThemeViewModel + + /// Finder-style ordering, matching `ImportFileItem`'s `Comparable` and the library's own + /// sort: "002" before "010", which plain string ordering gets wrong on the numbered + /// filenames audiobooks almost always have. Sorted here rather than per flow so every + /// adopter gets it. + private var sortedRows: [ImportConfirmationRow] { + rows.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + } + + var body: some View { + NavigationStack { + List { + Section { + ForEach(sortedRows) { row in + HStack(spacing: Spacing.S1) { + Button { + withAnimation { + onRemove(row.id) + } + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.red) + .bpFont(.titleLarge) + } + .buttonStyle(.plain) + .accessibilityLabel("delete_button") + + Image(systemName: row.icon) + .foregroundStyle(theme.linkColor) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(row.title) + .foregroundStyle(theme.primaryColor) + .bpFont(.footnote) + .lineLimit(1) + + if let subtitle = row.subtitle { + Text(subtitle) + .foregroundStyle(theme.secondaryColor) + .bpFont(.caption) + .lineLimit(1) + } + } + } + .listRowBackground(theme.systemBackgroundColor) + } + } header: { + Text( + String.localizedStringWithFormat("files_title".localized, rows.count) + .localizedCapitalized + ) + .bpFont(.subheadline) + .foregroundStyle(theme.secondaryColor) + .accessibilityAddTraits(.isHeader) + } + } + .applyListStyle(with: theme, background: theme.systemBackgroundColor) + /// Pinned rather than a section footer: this is the caveat to read BEFORE confirming, + /// and as a footer a long enough list scrolled it off screen entirely. `safeAreaInset` + /// also reserves its height, so the last row can still be scrolled clear of it. + .safeAreaInset(edge: .bottom) { + if let description { + Text(description) + .bpFont(.subheadline) + /// `primaryColor`, not secondary: on glass the muted tone loses too much + /// contrast against whatever rows happen to scroll behind it. + .foregroundStyle(theme.primaryColor) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal, Spacing.S) + .padding(.vertical, Spacing.S1) + /// An inset CARD, not a full-width capsule. `liquidGlassBackground` is built for + /// floating, content-sized chrome — the mini player and the player's bubble + /// buttons — so stretching it edge to edge produced a capsule with its ends + /// clipped to the screen. Margins plus an explicit rounded rect give it the room + /// the treatment expects. + .glassCard() + .padding(.horizontal, Spacing.S) + .padding(.bottom, Spacing.S2) + } + } + /// Set here rather than inherited: the two current call sites happen to sit under a + /// `.tint(theme.linkColor)`, but a reusable shell should not depend on an ancestor the + /// file flow will not have. `linkColor` also matches what UIKit puts on the bar — + /// the hand-rolled buttons this replaced used `primaryColor`, which never did. + .tint(theme.linkColor) + /// Cancelling has to be a choice: a swipe-away would discard a staged import by + /// accident. Deliberately STRICTER than the UIKit screen, which allows the swipe and + /// runs `try? discardImportOperation()` from `presentationControllerDidDismiss` — + /// cleanup whose failure it then swallows. The file flow should adopt this stricter + /// behaviour rather than restore that path. + .interactiveDismissDisabled() + .navigationTitle("import_title") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + onCancel() + } label: { + Image(systemName: "xmark") + } + .accessibilityLabel("cancel_button") + } + + ToolbarItem(placement: .confirmationAction) { + Button { + onConfirm() + } label: { + Image(systemName: "checkmark") + } + .accessibilityLabel("import_button") + /// Parity with `ImportViewController`, which disables Done at zero files: + /// confirming an emptied list is a no-op, so don't offer it. + .disabled(rows.isEmpty) + } + } + } + } +} diff --git a/BookPlayer/Info.plist b/BookPlayer/Info.plist index 4698569eb..5979512ce 100644 --- a/BookPlayer/Info.plist +++ b/BookPlayer/Info.plist @@ -26,10 +26,9 @@ BGTaskSchedulerPermittedIdentifiers - $(BP_BUNDLE_IDENTIFIER).background - $(BP_BUNDLE_IDENTIFIER).background.cellular $(BP_BUNDLE_IDENTIFIER).background.refresh $(BP_BUNDLE_IDENTIFIER).background.database.backup + $(BP_BUNDLE_IDENTIFIER).uploads.continued BP_API_DOMAIN $(BP_API_DOMAIN) diff --git a/BookPlayer/Jellyfin/JellyfinRootView.swift b/BookPlayer/Jellyfin/JellyfinRootView.swift index 04f67a862..be7e59c96 100644 --- a/BookPlayer/Jellyfin/JellyfinRootView.swift +++ b/BookPlayer/Jellyfin/JellyfinRootView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import BookPlayerKit struct JellyfinRootView: View { let connectionService: JellyfinConnectionService @@ -22,11 +23,14 @@ struct JellyfinRootView: View { } @EnvironmentObject private var singleFileDownloadService: SingleFileDownloadService + @EnvironmentObject private var externalImportEvents: ExternalImportEvents @EnvironmentObject private var theme: ThemeViewModel @Environment(\.dismiss) var dismiss @Environment(\.listState) private var listState + @Environment(\.accountService) private var accountService + init(connectionService: JellyfinConnectionService) { self.connectionService = connectionService self._connectionViewModel = .init( @@ -50,31 +54,24 @@ struct JellyfinRootView: View { var body: some View { TabView { Tab("books_title", systemImage: "books.vertical.fill") { - JellyfinTabRoot( - library: resolvedLibrary, - connectionService: connectionService, - singleFileDownloadService: singleFileDownloadService, - onDismiss: { listState.activeIntegrationSheet = nil }, - onSwitchLibrary: switchLibraryAction, - dismissAll: dismiss - ) - .id(resolvedLibrary?.id) - .toolbarBackground(.visible, for: .tabBar) - .toolbarBackground(theme.secondarySystemBackgroundColor, for: .tabBar) + tabRootView } Tab("Authors", systemImage: "person.2.fill") { - JellyfinEntityTabRoot( + JellyfinEntityTabRoot( connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + onImportConfirmed: { externalImportEvents.send($0) }, onDismiss: { listState.activeIntegrationSheet = nil }, onSwitchLibrary: switchLibraryAction, makeViewModel: { nav in - JellyfinAuthorsListViewModel( + JellyfinPersonsListViewModel( + role: .author, parentID: resolvedLibrary?.id, connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + accountService: accountService, navigation: nav, - navigationTitle: "Authors" + navigationTitle: "authors_title".localized ) } ) @@ -142,9 +139,7 @@ struct JellyfinRootView: View { } .environmentObject(theme) .onChange(of: availableLibraries) { _, libraries in - if let libraries, libraries.count > 1, resolvedLibrary == nil { - showLibraryPicker = true - } + handleAvailableLibrariesChange(libraries) } .onChange(of: connectionService.connection?.id) { _, newValue in // The active connection was deleted out from under this library — signing out from the @@ -166,6 +161,28 @@ struct JellyfinRootView: View { } } } + + private func handleAvailableLibrariesChange(_ libraries: [JellyfinLibraryItem]?) { + if let libraries, libraries.count > 1, resolvedLibrary == nil { + showLibraryPicker = true + } + } + + private var tabRootView: some View { + JellyfinTabRoot( + library: resolvedLibrary, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + onImportConfirmed: { externalImportEvents.send($0) }, + accountService: accountService, + onDismiss: { listState.activeIntegrationSheet = nil }, + onSwitchLibrary: switchLibraryAction, + dismissAll: dismiss + ) + .id(resolvedLibrary?.id) + .toolbarBackground(.visible, for: .tabBar) + .toolbarBackground(theme.secondarySystemBackgroundColor, for: .tabBar) + } // MARK: - Library Picker @@ -221,6 +238,7 @@ struct JellyfinRootView: View { } } .environmentObject(theme) + .interactiveDismissDisabled(resolvedLibrary == nil) } private func loadLibraries() async { @@ -247,34 +265,40 @@ struct JellyfinRootView: View { // MARK: - Books Tab (folder-based, same as original) -private struct JellyfinTabRoot: View { +struct JellyfinTabRoot: View { let connectionService: JellyfinConnectionService let singleFileDownloadService: SingleFileDownloadService + let onImportConfirmed: ([SimpleExternalResource]) -> Void + let accountService: AccountService let onDismiss: () -> Void var onSwitchLibrary: (() -> Void)? var dismissAll: DismissAction? - + @StateObject private var navigation = BPNavigation() @StateObject var viewModel: JellyfinLibraryViewModel @State private var isEditing = false @State private var showConnectionDetails = false - + @EnvironmentObject private var theme: ThemeViewModel - + init( library: JellyfinLibraryItem?, connectionService: JellyfinConnectionService, singleFileDownloadService: SingleFileDownloadService, + onImportConfirmed: @escaping ([SimpleExternalResource]) -> Void, + accountService: AccountService, onDismiss: @escaping () -> Void, onSwitchLibrary: (() -> Void)? = nil, dismissAll: DismissAction? = nil ) { self.connectionService = connectionService self.singleFileDownloadService = singleFileDownloadService + self.onImportConfirmed = onImportConfirmed + self.accountService = accountService self.onDismiss = onDismiss self.onSwitchLibrary = onSwitchLibrary self.dismissAll = dismissAll - + let navigation = BPNavigation() self._navigation = .init(wrappedValue: navigation) self._viewModel = .init( @@ -282,13 +306,15 @@ private struct JellyfinTabRoot: View { folderID: library?.id, connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + onImportConfirmed: onImportConfirmed, + accountService: accountService, navigation: navigation, navigationTitle: library?.name ?? "" ) ) } - - var body: some View { + + public var body: some View { NavigationStack(path: $navigation.path) { JellyfinLibraryView(viewModel: viewModel) .navigationBarTitleDisplayMode(.inline) @@ -318,13 +344,20 @@ private struct JellyfinTabRoot: View { .sheet(isPresented: $showConnectionDetails) { connectionDetailsSheet } + .sheet(isPresented: $navigation.showingSubscribe) { + NavigationStack { + MediaServersLoginView() + } + .tint(theme.linkColor) + .environmentObject(theme) + } .task { navigation.dismiss = onDismiss } } - + @ViewBuilder - private func destinationView(for destination: JellyfinLibraryLevelData) -> some View { + func destinationView(for destination: JellyfinLibraryLevelData) -> some View { switch destination { case .topLevel(let libraryName): JellyfinLibraryView( @@ -332,6 +365,8 @@ private struct JellyfinTabRoot: View { folderID: nil, connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + onImportConfirmed: onImportConfirmed, + accountService: accountService, navigation: navigation, navigationTitle: libraryName ) @@ -342,46 +377,60 @@ private struct JellyfinTabRoot: View { folderID: item.id, connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + onImportConfirmed: onImportConfirmed, + accountService: accountService, navigation: navigation, navigationTitle: item.name ) ) case .authorBooks(let authorID, let authorName, let parentID): JellyfinLibraryView( - viewModel: JellyfinAuthorBooksViewModel( - authorID: authorID, + viewModel: JellyfinPersonBooksViewModel( + role: .author, + personID: authorID, parentID: parentID, connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + onImportConfirmed: onImportConfirmed, + accountService: accountService, navigation: navigation, navigationTitle: authorName ) ) case .narratorBooks(let personID, let personName, let parentID): JellyfinLibraryView( - viewModel: JellyfinNarratorBooksViewModel( + viewModel: JellyfinPersonBooksViewModel( + role: .narrator, personID: personID, parentID: parentID, connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + onImportConfirmed: onImportConfirmed, + accountService: accountService, navigation: navigation, navigationTitle: personName ) ) case .details(let item): JellyfinAudiobookDetailsView( - viewModel: JellyfinAudiobookDetailsViewModel( - item: item, - connectionService: connectionService, - singleFileDownloadService: singleFileDownloadService - ) + initModel: { + JellyfinAudiobookDetailsViewModel( + item: item, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + accountService: accountService, + onImportConfirmed: onImportConfirmed, + navigation: navigation, + navigationTitle: item.name + ) + } ) { onDismiss() } } } - - private var cogMenu: some View { + + var cogMenu: some View { Menu { Button { showConnectionDetails = true @@ -399,8 +448,8 @@ private struct JellyfinTabRoot: View { } .accessibilityLabel("settings_title") } - - private var connectionDetailsSheet: some View { + + var connectionDetailsSheet: some View { NavigationStack { IntegrationSettingsView(integrationName: "Jellyfin") { JellyfinConnectionViewModel( @@ -431,28 +480,29 @@ private struct JellyfinTabRoot: View { .environmentObject(theme) } } - + // MARK: - Entity Tab Root (Authors / Narrators) // Reuses JellyfinTabRoot structure but with a list-specific ViewModel - -private struct JellyfinEntityTabRoot: View +struct JellyfinEntityTabRoot: View where ViewModel.Item == JellyfinLibraryItem { let connectionService: JellyfinConnectionService let singleFileDownloadService: SingleFileDownloadService + let onImportConfirmed: ([SimpleExternalResource]) -> Void let onDismiss: () -> Void var onSwitchLibrary: (() -> Void)? var dismissAll: DismissAction? - + @StateObject private var navigation = BPNavigation() @StateObject var viewModel: ViewModel @State private var isEditing = false @State private var showConnectionDetails = false - + @EnvironmentObject private var theme: ThemeViewModel - + init( connectionService: JellyfinConnectionService, singleFileDownloadService: SingleFileDownloadService, + onImportConfirmed: @escaping ([SimpleExternalResource]) -> Void, onDismiss: @escaping () -> Void, onSwitchLibrary: (() -> Void)? = nil, dismissAll: DismissAction? = nil, @@ -460,16 +510,17 @@ where ViewModel.Item == JellyfinLibraryItem { ) { self.connectionService = connectionService self.singleFileDownloadService = singleFileDownloadService + self.onImportConfirmed = onImportConfirmed self.onDismiss = onDismiss self.onSwitchLibrary = onSwitchLibrary self.dismissAll = dismissAll - + let navigation = BPNavigation() let vm = makeViewModel(navigation) self._navigation = .init(wrappedValue: navigation) self._viewModel = .init(wrappedValue: vm) } - + var body: some View { NavigationStack(path: $navigation.path) { JellyfinLibraryView(viewModel: viewModel) @@ -479,6 +530,8 @@ where ViewModel.Item == JellyfinLibraryItem { for: destination, connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + onImportConfirmed: onImportConfirmed, + accountService: viewModel.accountService, navigation: navigation, onDismiss: onDismiss ) @@ -516,20 +569,27 @@ where ViewModel.Item == JellyfinLibraryItem { dismissAll: dismissAll ) } + .sheet(isPresented: $navigation.showingSubscribe) { + NavigationStack { + MediaServersLoginView() + } + .tint(theme.linkColor) + .environmentObject(theme) + } .task { navigation.dismiss = onDismiss } } } -// MARK: - Shared helpers on JellyfinTabRoot - extension JellyfinTabRoot { @ViewBuilder static func sharedDestinationView( for destination: JellyfinLibraryLevelData, connectionService: JellyfinConnectionService, singleFileDownloadService: SingleFileDownloadService, + onImportConfirmed: @escaping ([SimpleExternalResource]) -> Void, + accountService: AccountService, navigation: BPNavigation, onDismiss: @escaping () -> Void ) -> some View { @@ -540,6 +600,8 @@ extension JellyfinTabRoot { folderID: nil, connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + onImportConfirmed: onImportConfirmed, + accountService: accountService, navigation: navigation, navigationTitle: libraryName ) @@ -550,45 +612,59 @@ extension JellyfinTabRoot { folderID: item.id, connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + onImportConfirmed: onImportConfirmed, + accountService: accountService, navigation: navigation, navigationTitle: item.name ) ) case .authorBooks(let authorID, let authorName, let parentID): JellyfinLibraryView( - viewModel: JellyfinAuthorBooksViewModel( - authorID: authorID, + viewModel: JellyfinPersonBooksViewModel( + role: .author, + personID: authorID, parentID: parentID, connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + onImportConfirmed: onImportConfirmed, + accountService: accountService, navigation: navigation, navigationTitle: authorName ) ) case .narratorBooks(let personID, let personName, let parentID): JellyfinLibraryView( - viewModel: JellyfinNarratorBooksViewModel( + viewModel: JellyfinPersonBooksViewModel( + role: .narrator, personID: personID, parentID: parentID, connectionService: connectionService, singleFileDownloadService: singleFileDownloadService, + onImportConfirmed: onImportConfirmed, + accountService: accountService, navigation: navigation, navigationTitle: personName ) ) case .details(let item): JellyfinAudiobookDetailsView( - viewModel: JellyfinAudiobookDetailsViewModel( - item: item, - connectionService: connectionService, - singleFileDownloadService: singleFileDownloadService - ) + initModel: { + JellyfinAudiobookDetailsViewModel( + item: item, + connectionService: connectionService, + singleFileDownloadService: singleFileDownloadService, + accountService: accountService, + onImportConfirmed: onImportConfirmed, + navigation: navigation, + navigationTitle: item.name + ) + } ) { onDismiss() } } } - + static func cogMenuView( theme: ThemeViewModel, connectionService: JellyfinConnectionService, @@ -612,7 +688,7 @@ extension JellyfinTabRoot { } .accessibilityLabel("settings_title") } - + static func connectionDetailsSheetView( connectionService: JellyfinConnectionService, showConnectionDetails: Binding, diff --git a/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsView.swift b/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsView.swift index 3162eaeb7..b3030ed01 100644 --- a/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsView.swift +++ b/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import BookPlayerKit /// Thin wrapper providing Jellyfin-specific image view to the shared details view. struct JellyfinAudiobookDetailsView< @@ -14,9 +15,23 @@ struct JellyfinAudiobookDetailsView< >: View where Model.Item == JellyfinLibraryItem, Model.Details == JellyfinAudiobookDetailsData { - @ObservedObject var viewModel: Model + /// OWNS the VM (repo pattern, see ItemListView.init(initModel:)): this wrapper is + /// re-created whenever the root's navigationDestination builder re-evaluates (e.g. + /// tapping Stream mutates importManager, an @EnvironmentObject of the root), and an + /// @ObservedObject over an inline-constructed VM was recreated each time — losing + /// error/isImporting state and refetching. @StateObject storage survives; the + /// initModel closure runs only on first install. + @StateObject var viewModel: Model var onDownloadTap: () -> Void + init( + initModel: @escaping () -> Model, + onDownloadTap: @escaping () -> Void + ) { + self._viewModel = .init(wrappedValue: initModel()) + self.onDownloadTap = onDownloadTap + } + var body: some View { IntegrationAudiobookDetailsView( viewModel: viewModel, diff --git a/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsViewModel.swift b/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsViewModel.swift index 8e6d430e0..b6925a6f3 100644 --- a/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsViewModel.swift +++ b/BookPlayer/Jellyfin/Library Screen/Details/JellyfinAudiobookDetailsViewModel.swift @@ -7,44 +7,39 @@ // import BookPlayerKit +import Combine import Foundation import JellyfinAPI -struct JellyfinAudiobookDetailsData: IntegrationDetailsDataProtocol { - let artist: String? - let filePath: String? - let fileSize: Int? - let overview: String? - let runtimeInSeconds: TimeInterval? - let genres: [String]? - let tags: [String]? - - var fileSizeString: String { - if let fileSize { - ByteCountFormatter.string( - fromByteCount: Int64(fileSize), - countStyle: ByteCountFormatter.CountStyle.file - ) - } else { - "file_size_unknown".localized - } - } - - var runtimeString: String { - if let runtimeInSeconds { - return TimeParser.formatTotalDuration(runtimeInSeconds) - } else { - return "runtime_unknown".localized - } - } -} - class JellyfinAudiobookDetailsViewModel: IntegrationDetailsViewModelProtocol { - + typealias Item = JellyfinLibraryItem + typealias Details = JellyfinAudiobookDetailsData + let item: JellyfinLibraryItem let connectionService: JellyfinConnectionService + let accountService: AccountService + let onImportConfirmed: ([SimpleExternalResource]) -> Void + let navigation: BPNavigation + let navigationTitle: String @Published var details: JellyfinAudiobookDetailsData? @Published var error: Error? + @Published private(set) var isImporting = false + @Published var pendingImportBatch: ExternalImportBatch? + private var disposeBag = Set() + + var allowStream: Bool { accountService.hasStreamingEnabled() } + + @MainActor + func confirmExternalImport(_ resources: [SimpleExternalResource]) { + // Details imports keep you in the browser (today's flow — import another book + // without re-navigating): send on the import bus, no dismissal + onImportConfirmed(resources) + } + + @MainActor + func goToSubscribe() { + navigation.showingSubscribe = true + } private var singleFileDownloadService: SingleFileDownloadService private var fetchTask: Task<(), any Error>? @@ -52,11 +47,27 @@ class JellyfinAudiobookDetailsViewModel: IntegrationDetailsViewModelProtocol { init( item: JellyfinLibraryItem, connectionService: JellyfinConnectionService, - singleFileDownloadService: SingleFileDownloadService + singleFileDownloadService: SingleFileDownloadService, + accountService: AccountService, + onImportConfirmed: @escaping ([SimpleExternalResource]) -> Void, + navigation: BPNavigation, + navigationTitle: String ) { self.item = item self.connectionService = connectionService self.singleFileDownloadService = singleFileDownloadService + self.accountService = accountService + self.onImportConfirmed = onImportConfirmed + self.navigation = navigation + self.navigationTitle = navigationTitle + self.details = nil + + // Entitlements can change while this screen is on the stack (the Stream CTA + // pushes the subscribe flow) — re-render on account updates so the CTAs flip + NotificationCenter.default.publisher(for: .accountUpdate) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &disposeBag) } @MainActor @@ -69,10 +80,10 @@ class JellyfinAudiobookDetailsViewModel: IntegrationDetailsViewModelProtocol { defer { fetchTask = nil } do { - let details = try await connectionService.fetchItemDetails(for: item.id) + let detailsData = try await connectionService.fetchItemDetails(for: item.id) await MainActor.run { - self.details = details + self.details = detailsData } } catch is CancellationError { // ignore @@ -89,10 +100,68 @@ class JellyfinAudiobookDetailsViewModel: IntegrationDetailsViewModelProtocol { fetchTask?.cancel() fetchTask = nil } + + @MainActor + func handleImportAudiobook(_ item: JellyfinLibraryItem) async throws { + if accountService.hasStreamingEnabled() { + await virtualImportAudiobook(item) + } else { + try beginDownloadAudiobook(item) + } + } @MainActor func beginDownloadAudiobook(_ item: JellyfinLibraryItem) throws { let request = try connectionService.createItemDownloadRequest(item) singleFileDownloadService.handleDownload(request) } + + @MainActor + func virtualImportAudiobook(_ item: JellyfinLibraryItem) async { + // Reentrancy guard: a double-tap mid-hydration must not run two imports + guard !isImporting else { return } + isImporting = true + defer { isImporting = false } + + do { + let resources = try await VirtualImportPipeline.run( + items: [item], + id: \.id, + hydrate: { ids in + // Same contract as the bulk paths: the extension and the runtime come from the + // server's media sources or the item is not importable — never guessed, never + // defaulted to a 0 length + let hydrated = try await self.connectionService.fetchItems(ids: ids) + var hydratedByID: [String: HydratedItem] = [:] + hydratedByID[item.id] = HydratedItem( + fileExtension: hydrated.first?.details?.fileExtension ?? self.details?.fileExtension, + // `details.runtimeInSeconds`, never `durationSeconds`: the mapper collapses an + // unmeasured runtime to 0 rather than nil, so reading it here would satisfy the + // `??` with a zero and skip the fallback in exactly the case it exists for + duration: hydrated.first?.details?.runtimeInSeconds ?? self.details?.runtimeInSeconds, + chapters: hydrated.first?.chapters ?? [] + ) + return hydratedByID + }, + buildResource: { item, hydrated in + item.asVirtualImportResource( + fileExtension: hydrated.fileExtension, + duration: hydrated.duration, + chapters: hydrated.chapters, + detailsOverride: self.details, + connectionService: self.connectionService, + artworkSize: CGSize(width: 200, height: 200) + ) + } + ) + guard !resources.isEmpty else { + self.error = BookPlayerError.runtimeError("import_no_audio_files_alert".localized) + return + } + // Stage as a VALUE for this screen's own confirmation sheet + pendingImportBatch = ExternalImportBatch(resources: resources) + } catch { + self.error = error + } + } } diff --git a/BookPlayer/Jellyfin/Library Screen/GridLayout/JellyfinLibraryGridItemView.swift b/BookPlayer/Jellyfin/Library Screen/GridLayout/JellyfinLibraryGridItemView.swift index e720e5e06..b436c51f9 100644 --- a/BookPlayer/Jellyfin/Library Screen/GridLayout/JellyfinLibraryGridItemView.swift +++ b/BookPlayer/Jellyfin/Library Screen/GridLayout/JellyfinLibraryGridItemView.swift @@ -15,7 +15,7 @@ struct JellyfinLibraryGridItemView: View { @State var item: JellyfinLibraryItem - @ScaledMetric var accessabilityScale: CGFloat = 1 + @ScaledMetric var accessibilityScale: CGFloat = 1 @State private var imageSize: CGSize = CGSize.zero var isSelected: Bool @@ -33,7 +33,7 @@ struct JellyfinLibraryGridItemView: View { } ) .overlay { - if editMode?.wrappedValue.isEditing == true, item.kind == .audiobook { + if editMode?.wrappedValue.isEditing == true, item.isDownloadable { Image(systemName: isSelected ? "checkmark.circle" : "circle") .foregroundStyle(.white) .background(isSelected ? .blue : .clear) @@ -60,7 +60,7 @@ struct JellyfinLibraryGridItemView: View { private var folderBadge: some View { let imageLength = min(imageSize.width, imageSize.height) ZStack { - Circle().strokeBorder(.foreground, lineWidth: 1 * accessabilityScale) + Circle().strokeBorder(.foreground, lineWidth: 1 * accessibilityScale) .background(Circle().fill(.background)) Image(systemName: "folder.fill") .resizable() diff --git a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryItem.swift b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryItem.swift deleted file mode 100644 index cf4d87354..000000000 --- a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryItem.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// JellyfinLibraryItem.swift -// BookPlayer -// -// Created by Lysann Tranvouez on 2024-10-26. -// Copyright © 2024 BookPlayer LLC. All rights reserved. -// - -import Foundation -import JellyfinAPI - -struct JellyfinLibraryItem: IntegrationLibraryItemProtocol { - enum Kind { - case userView - case folder - case audiobook - case author - case narrator - } - - let id: String - let name: String - let kind: Kind - - let blurHash: String? - let imageAspectRatio: Double? - - var isDownloadable: Bool { - kind == .audiobook - } - - var isNavigable: Bool { - !isDownloadable - } - - var displayName: String { name } - - var placeholderImageName: String { - switch kind { - case .audiobook: "waveform" - case .userView, .folder: "folder" - case .author: "person" - case .narrator: "mic" - } - } -} - -extension JellyfinLibraryItem { - init(id: String, name: String, kind: Kind) { - self.init(id: id, name: name, kind: kind, blurHash: nil, imageAspectRatio: nil) - } -} - -extension JellyfinLibraryItem { - init?(apiItem: BaseItemDto) { - let kind: JellyfinLibraryItem.Kind? = switch apiItem.type { - case .userView, .collectionFolder: .userView - case .folder: .folder - case .audioBook: .audiobook - default: nil - } - - guard let id = apiItem.id, let kind else { - return nil - } - let name = apiItem.name ?? id - let blurHash = apiItem.imageBlurHashes?.primary?.first?.value - - self.init(id: id, name: name, kind: kind, blurHash: blurHash, imageAspectRatio: apiItem.primaryImageAspectRatio) - } - - /// Create an author item from an AlbumArtists API response - init?(authorApiItem: BaseItemDto) { - guard let id = authorApiItem.id else { return nil } - let name = authorApiItem.name ?? id - let blurHash = authorApiItem.imageBlurHashes?.primary?.first?.value - self.init(id: id, name: name, kind: .author, blurHash: blurHash, imageAspectRatio: authorApiItem.primaryImageAspectRatio) - } - - /// Create a narrator item from a Persons API response - init?(narratorApiItem: BaseItemDto) { - guard let id = narratorApiItem.id else { return nil } - let name = narratorApiItem.name ?? id - let blurHash = narratorApiItem.imageBlurHashes?.primary?.first?.value - self.init(id: id, name: name, kind: .narrator, blurHash: blurHash, imageAspectRatio: narratorApiItem.primaryImageAspectRatio) - } -} diff --git a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryView.swift b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryView.swift index 1fadc20c4..255fe4ae1 100644 --- a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryView.swift +++ b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryView.swift @@ -7,7 +7,7 @@ // import SwiftUI - +import BookPlayerKit /// Thin wrapper providing Jellyfin-specific cell, row, sort picker, and environment /// to the shared `IntegrationLibraryView`. struct JellyfinLibraryView: View @@ -35,10 +35,8 @@ where Model.Item == JellyfinLibraryItem { private var jellyfinConnectionService: JellyfinConnectionService { (viewModel as? JellyfinLibraryViewModel)?.connectionService - ?? (viewModel as? JellyfinAuthorBooksViewModel)?.connectionService - ?? (viewModel as? JellyfinNarratorBooksViewModel)?.connectionService - ?? (viewModel as? JellyfinAuthorsListViewModel)?.connectionService - ?? (viewModel as? JellyfinNarratorsListViewModel)?.connectionService + ?? (viewModel as? JellyfinPersonBooksViewModel)?.connectionService + ?? (viewModel as? JellyfinPersonsListViewModel)?.connectionService ?? .init() } @@ -48,10 +46,10 @@ where Model.Item == JellyfinLibraryItem { Picker(selection: Binding( get: { vm.sortBy }, set: { vm.sortBy = $0 } - ), label: Text("Sort by".localized)) { - Text("Default".localized).tag(JellyfinLayout.SortBy.smart) + ), label: Text("sort_by_title")) { + Text("sort_default_option").tag(JellyfinLayout.SortBy.smart) Label("sort_most_recent_button", systemImage: "clock").tag(JellyfinLayout.SortBy.recent) - Label("Name".localized, systemImage: "textformat.abc").tag(JellyfinLayout.SortBy.name) + Label("sort_name_option", systemImage: "textformat.abc").tag(JellyfinLayout.SortBy.name) } } } diff --git a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryViewModel.swift b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryViewModel.swift index c5ceed50e..23d0ee278 100644 --- a/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryViewModel.swift +++ b/BookPlayer/Jellyfin/Library Screen/JellyfinLibraryViewModel.swift @@ -21,14 +21,113 @@ enum JellyfinLibraryLevelData: Equatable, Hashable { case details(data: JellyfinLibraryItem) } -enum JellyfinLayout { - enum SortBy: String { - case recent, name, smart +/// The author/narrator browse screens are the same VM with a different fetch + +/// destination — parameterized by role instead of duplicated per class. +enum JellyfinPersonRole { + case author + case narrator +} + +// MARK: - Shared folder-import flow + +/// The two Jellyfin book-listing VMs share the identical import flow verbatim; the +/// persons-list VM never imports and deliberately does not conform. onStreamTapped +/// and confirmExternalImport are witnessed by the extension defaults below; +/// virtualImportFolderAudiobooks stays a per-VM wrapper so the isImporting reentrancy +/// guard keeps its @Published private(set) access. +@MainActor +protocol JellyfinFolderImporting: AnyObject, BPLogger { + var items: [JellyfinLibraryItem] { get } + var selectedItems: Set { get } + var connectionService: JellyfinConnectionService { get } + var accountService: AccountService { get } + var navigation: BPNavigation { get } + var onImportConfirmed: ([SimpleExternalResource]) -> Void { get } + var error: Error? { get set } + var pendingImportBatch: ExternalImportBatch? { get set } + + func virtualImportFolderAudiobooks(useSelectedItems: Bool) async + func onDownloadTapped() + func confirmDownloadFolder() + func goToSubscribe() +} + +extension JellyfinFolderImporting { + /// Stream never downloads: without the entitlement it sells the entitlement. + @MainActor + func onStreamTapped(useSelectedItems: Bool) { + guard accountService.hasStreamingEnabled() else { + goToSubscribe() + return + } + Task { await virtualImportFolderAudiobooks(useSelectedItems: useSelectedItems) } + } + + @MainActor + func confirmExternalImport(_ resources: [SimpleExternalResource]) { + // Bulk imports land you in the library (today's destination): send the batch on + // the import bus, then close the browser + onImportConfirmed(resources) + navigation.dismiss?() + } + + /// The shared import body — callers hold the isImporting guard. + func runFolderImport(useSelectedItems: Bool) async { + // Both branches filter on isDownloadable so a selection can never carry something + // the whole-level branch would have skipped + let audiobooks = useSelectedItems + ? selectedItems.compactMap({ id in + self.items.first(where: { $0.id == id && $0.isDownloadable }) + }) + : self.items.filter { $0.isDownloadable } + + guard !audiobooks.isEmpty else { return } + + do { + let resources = try await VirtualImportPipeline.run( + items: audiobooks, + id: \.id, + hydrate: { ids in + let hydrated = try await self.connectionService.fetchItems(ids: ids) + return hydrated.reduce(into: [:]) { + $0[$1.id] = HydratedItem( + fileExtension: $1.details?.fileExtension, + // `details.runtimeInSeconds` keeps "unmeasured" as nil and avoids the whole-second + // truncation `durationSeconds` applies + duration: $1.details?.runtimeInSeconds, + chapters: $1.chapters + ) + } + }, + buildResource: { item, hydrated in + item.asVirtualImportResource( + fileExtension: hydrated.fileExtension, + duration: hydrated.duration, + chapters: hydrated.chapters, + detailsOverride: nil, + connectionService: self.connectionService, + artworkSize: CGSize(width: 200, height: 200) + ) + } + ) + guard !resources.isEmpty else { + self.error = BookPlayerError.runtimeError("import_no_audio_files_alert".localized) + return + } + if resources.count < audiobooks.count { + Self.logger.warning("Virtual import skipped \(audiobooks.count - resources.count) item(s) with no audio-file metadata or no server-measured length") + } + // Stage as a VALUE for this screen's own confirmation sheet — the browser + // stays open beneath it; dismissal happens on confirm + pendingImportBatch = ExternalImportBatch(resources: resources) + } catch { + self.error = error + } } } @MainActor -final class JellyfinLibraryViewModel: IntegrationLibraryViewModelProtocol, BPLogger { +final class JellyfinLibraryViewModel: IntegrationLibraryViewModelProtocol, JellyfinFolderImporting, BPLogger { enum Routes { case done } @@ -43,6 +142,7 @@ final class JellyfinLibraryViewModel: IntegrationLibraryViewModelProtocol, BPLog var sortBy: JellyfinLayout.SortBy = .smart { didSet { guard let folderID = folderID else { return } + fetchTask?.cancel() items = [] nextStartItemIndex = 0 totalItems = Int.max @@ -54,10 +154,18 @@ final class JellyfinLibraryViewModel: IntegrationLibraryViewModelProtocol, BPLog @Published var items: [JellyfinLibraryItem] = [] @Published var totalItems = Int.max @Published var error: Error? + @Published private(set) var isImporting = false + @Published var pendingImportBatch: ExternalImportBatch? @Published var editMode: EditMode = .inactive @Published var selectedItems: Set = [] @Published var showingDownloadConfirmation = false + @Published private(set) var isPreparingDownload = false + + /// Resolved BEFORE the confirmation so the dialog can state the real figure: this + /// folder's download reaches the complete level, which `items` alone can't count. + /// Held only between `onDownloadFolderTapped` and `confirmDownloadFolder`. + private var pendingDownloadRequests: [URLRequest] = [] var isSearchable: Bool { true } @@ -65,7 +173,9 @@ final class JellyfinLibraryViewModel: IntegrationLibraryViewModelProtocol, BPLog let folderID: String? let recursive: Bool + let onImportConfirmed: ([SimpleExternalResource]) -> Void let connectionService: JellyfinConnectionService + var accountService: AccountService private let singleFileDownloadService: SingleFileDownloadService private var fetchTask: Task<(), any Error>? @@ -80,18 +190,31 @@ final class JellyfinLibraryViewModel: IntegrationLibraryViewModelProtocol, BPLog nextStartItemIndex < totalItems } + /// While a confirmation is pending this is the exact number of requests about to run. + /// `totalItems` is not usable here: a server that omits `totalRecordCount` makes + /// `updatedTotal` publish `items.count + itemBatchSize` as a pagination probe. + var downloadableItemCount: Int { + showingDownloadConfirmation + ? pendingDownloadRequests.count + : items.filter { $0.isDownloadable }.count + } + init( folderID: String?, recursive: Bool = false, connectionService: JellyfinConnectionService, singleFileDownloadService: SingleFileDownloadService, + onImportConfirmed: @escaping ([SimpleExternalResource]) -> Void, + accountService: AccountService, navigation: BPNavigation, navigationTitle: String ) { self.folderID = folderID self.recursive = recursive self.connectionService = connectionService + self.onImportConfirmed = onImportConfirmed self.singleFileDownloadService = singleFileDownloadService + self.accountService = accountService self.navigation = navigation self.navigationTitle = navigationTitle @@ -286,6 +409,8 @@ final class JellyfinLibraryViewModel: IntegrationLibraryViewModelProtocol, BPLog @MainActor func onSelectTapped(for item: JellyfinLibraryItem) { + guard item.isDownloadable else { return } + if let index = selectedItems.firstIndex(of: item.id) { selectedItems.remove(at: index) } else { @@ -297,7 +422,7 @@ final class JellyfinLibraryViewModel: IntegrationLibraryViewModelProtocol, BPLog func onSelectAllTapped() { if selectedItems.isEmpty { let ids: [JellyfinLibraryItem.ID] = items.compactMap { item in - guard item.kind == .audiobook else { return nil } + guard item.isDownloadable else { return nil } return item.id } @@ -310,7 +435,7 @@ final class JellyfinLibraryViewModel: IntegrationLibraryViewModelProtocol, BPLog @MainActor func onDownloadTapped() { let items = selectedItems.compactMap({ id in - self.items.first(where: { $0.id == id }) + self.items.first(where: { $0.id == id && $0.isDownloadable }) }) var requests = [URLRequest]() @@ -322,36 +447,58 @@ final class JellyfinLibraryViewModel: IntegrationLibraryViewModelProtocol, BPLog self.error = error } } + // Every request failing must not read as success: surface the error already set + // above and leave the browser open, matching the AudiobookShelf path + guard !requests.isEmpty else { return } singleFileDownloadService.handleDownload(requests) navigation.dismiss?() } + /// Resolves the request list BEFORE showing the confirmation, so the dialog states the + /// number that will actually download rather than the number currently paged in. This is + /// the same call the download used to make afterwards — moved, not duplicated — and + /// `getAllAudiobookDownloadRequests` still short-circuits a fully loaded folder with no + /// network at all. @MainActor func onDownloadFolderTapped() { - showingDownloadConfirmation = true - } - - @MainActor - func confirmDownloadFolder() { - guard let folderID else { return } + guard let folderID, !isPreparingDownload else { return } + isPreparingDownload = true Task { @MainActor [weak self] in guard let self else { return } + defer { self.isPreparingDownload = false } do { let requests = try await self.getAllAudiobookDownloadRequests(for: folderID) - self.singleFileDownloadService.handleDownload(requests, folderName: self.navigationTitle) - self.navigation.dismiss?() + // getAllAudiobookDownloadRequests swallows per-item failures with try?, so an + // empty array means nothing could be built — say so instead of opening a + // dialog that would download nothing. + guard !requests.isEmpty else { + self.error = BookPlayerError.runtimeError("download_prepare_error".localized) + return + } + self.pendingDownloadRequests = requests + self.showingDownloadConfirmation = true } catch { self.error = error } } } + @MainActor + func confirmDownloadFolder() { + let requests = pendingDownloadRequests + pendingDownloadRequests = [] + guard !requests.isEmpty else { return } + + singleFileDownloadService.handleDownload(requests, folderName: navigationTitle) + navigation.dismiss?() + } + @MainActor private func getAllAudiobookDownloadRequests(for folderID: String) async throws -> [URLRequest] { if items.count == totalItems { - let audiobooks = items.filter { $0.kind == .audiobook } + let audiobooks = items.filter { $0.isDownloadable } return audiobooks.compactMap { audiobook in try? connectionService.createItemDownloadRequest(audiobook) } @@ -359,13 +506,30 @@ final class JellyfinLibraryViewModel: IntegrationLibraryViewModelProtocol, BPLog return try await connectionService.fetchAudiobookDownloadRequests(for: folderID) } } + + @MainActor + func virtualImportFolderAudiobooks(useSelectedItems: Bool) async { + // Reentrancy guard stays per-VM (isImporting keeps private(set)); the shared + // body lives in JellyfinFolderImporting.runFolderImport + guard !isImporting else { return } + isImporting = true + defer { isImporting = false } + await runFolderImport(useSelectedItems: useSelectedItems) + } + + @MainActor + func goToSubscribe() { + navigation.showingSubscribe = true + } + } // MARK: - Author Books ViewModel @MainActor -final class JellyfinAuthorBooksViewModel: IntegrationLibraryViewModelProtocol, BPLogger { - let authorID: String +final class JellyfinPersonBooksViewModel: IntegrationLibraryViewModelProtocol, JellyfinFolderImporting, BPLogger { + let role: JellyfinPersonRole + let personID: String let parentID: String? var navigation: BPNavigation @@ -381,31 +545,45 @@ final class JellyfinAuthorBooksViewModel: IntegrationLibraryViewModelProtocol, B @Published var items: [JellyfinLibraryItem] = [] @Published var totalItems = Int.max @Published var error: Error? + @Published private(set) var isImporting = false + @Published var pendingImportBatch: ExternalImportBatch? @Published var editMode: EditMode = .inactive @Published var selectedItems: Set = [] @Published var showingDownloadConfirmation = false var isSearchable: Bool { true } + /// Same reason as the persons list: `JellyfinLibraryView.sortPickerContent` only builds + /// a picker for `JellyfinLibraryViewModel`, so leaving this on renders an empty section. + /// Surfacing this screen's live `sortBy` would mean extending that picker — its own change. + var showsSortPreferences: Bool { false } + let onImportConfirmed: ([SimpleExternalResource]) -> Void let connectionService: JellyfinConnectionService + var accountService: AccountService private let singleFileDownloadService: SingleFileDownloadService private var fetchTask: Task<(), any Error>? private var allItems: [JellyfinLibraryItem] = [] private var disposeBag = Set() init( - authorID: String, + role: JellyfinPersonRole, + personID: String, parentID: String?, connectionService: JellyfinConnectionService, singleFileDownloadService: SingleFileDownloadService, + onImportConfirmed: @escaping ([SimpleExternalResource]) -> Void, + accountService: AccountService, navigation: BPNavigation, navigationTitle: String ) { - self.authorID = authorID + self.role = role + self.personID = personID self.parentID = parentID self.connectionService = connectionService self.singleFileDownloadService = singleFileDownloadService + self.onImportConfirmed = onImportConfirmed + self.accountService = accountService self.navigation = navigation self.navigationTitle = navigationTitle @@ -422,14 +600,27 @@ final class JellyfinAuthorBooksViewModel: IntegrationLibraryViewModelProtocol, B fetchTask = Task { @MainActor in defer { self.fetchTask = nil } do { - let (items, _, _) = try await connectionService.fetchItemsByArtist( - artistID: authorID, - parentID: parentID, - startIndex: 0, - limit: nil, - sortBy: sortBy - ) - self.allItems = items + let result: ([JellyfinLibraryItem], Int, Int) + switch role { + case .author: + result = try await connectionService.fetchItemsByArtist( + artistID: personID, + parentID: parentID, + startIndex: 0, + limit: nil, + sortBy: sortBy + ) + case .narrator: + result = try await connectionService.fetchItemsByPerson( + personID: personID, + personName: navigationTitle, + parentID: parentID, + startIndex: 0, + limit: nil, + sortBy: sortBy + ) + } + self.allItems = result.0 applySearch() } catch is CancellationError { // ignore @@ -502,167 +693,41 @@ final class JellyfinAuthorBooksViewModel: IntegrationLibraryViewModelProtocol, B singleFileDownloadService.handleDownload(requests) navigation.dismiss?() } - - @MainActor func onDownloadFolderTapped() {} - @MainActor func confirmDownloadFolder() {} - - private func applySearch() { - let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) - if query.isEmpty { - items = allItems - } else { - items = allItems.filter { $0.name.localizedCaseInsensitiveContains(query) } - } - totalItems = items.count - } -} - -// MARK: - Narrator Books ViewModel - -@MainActor -final class JellyfinNarratorBooksViewModel: IntegrationLibraryViewModelProtocol, BPLogger { - let personID: String - let parentID: String? - - var navigation: BPNavigation - let navigationTitle: String - - @AppStorage(Constants.UserDefaults.jellyfinLibraryLayout) - var layout: IntegrationLayout.Options = .grid - - @AppStorage(Constants.UserDefaults.jellyfinLibraryLayoutSortBy) - var sortBy: JellyfinLayout.SortBy = .smart - - @Published var searchQuery = "" - @Published var items: [JellyfinLibraryItem] = [] - @Published var totalItems = Int.max - @Published var error: Error? - - @Published var editMode: EditMode = .inactive - @Published var selectedItems: Set = [] - @Published var showingDownloadConfirmation = false - - var isSearchable: Bool { true } - - let connectionService: JellyfinConnectionService - private let singleFileDownloadService: SingleFileDownloadService - private var fetchTask: Task<(), any Error>? - private var allItems: [JellyfinLibraryItem] = [] - private var disposeBag = Set() - - init( - personID: String, - parentID: String?, - connectionService: JellyfinConnectionService, - singleFileDownloadService: SingleFileDownloadService, - navigation: BPNavigation, - navigationTitle: String - ) { - self.personID = personID - self.parentID = parentID - self.connectionService = connectionService - self.singleFileDownloadService = singleFileDownloadService - self.navigation = navigation - self.navigationTitle = navigationTitle - - $searchQuery - .debounce(for: .milliseconds(350), scheduler: RunLoop.main) - .removeDuplicates() - .dropFirst() - .sink { [weak self] _ in self?.applySearch() } - .store(in: &disposeBag) - } - - func fetchInitialItems() { - guard items.isEmpty, fetchTask == nil else { return } - fetchTask = Task { @MainActor in - defer { self.fetchTask = nil } - do { - let (items, _, _) = try await connectionService.fetchItemsByPerson( - personID: personID, - personName: navigationTitle, - parentID: parentID, - startIndex: 0, - limit: nil, - sortBy: sortBy - ) - self.allItems = items - applySearch() - } catch is CancellationError { - // ignore - } catch { - self.error = error - } - } - } - - func fetchMoreItemsIfNeeded(currentItem: JellyfinLibraryItem) {} - - func cancelFetchItems() { - fetchTask?.cancel() - fetchTask = nil - } - - func destination(for item: JellyfinLibraryItem) -> JellyfinLibraryLevelData? { - switch item.kind { - case .audiobook: .details(data: item) - case .folder: .folder(data: item) - default: nil - } - } - - @MainActor func handleDoneAction() {} - + + @MainActor - func onEditToggleSelectTapped() { - withAnimation { - editMode = editMode.isEditing ? .inactive : .active - } - if !editMode.isEditing { selectedItems.removeAll() } + func virtualImportFolderAudiobooks(useSelectedItems: Bool) async { + // Reentrancy guard stays per-VM (isImporting keeps private(set)); the shared + // body lives in JellyfinFolderImporting.runFolderImport + guard !isImporting else { return } + isImporting = true + defer { isImporting = false } + await runFolderImport(useSelectedItems: useSelectedItems) } @MainActor - func onSelectTapped(for item: JellyfinLibraryItem) { - guard item.isDownloadable else { return } - if selectedItems.contains(item.id) { - selectedItems.remove(item.id) - } else { - selectedItems.insert(item.id) - } + func onDownloadFolderTapped() { + showingDownloadConfirmation = true } + /// Unlike the folder VM there is no `folderID` to fetch against — this level is a query + /// result. It doesn't need one: `fetchInitialItems` asks for `limit: nil` and + /// `fetchMoreItemsIfNeeded` is a no-op, so `items` is always the complete level. @MainActor - func onSelectAllTapped() { - if selectedItems.isEmpty { - selectedItems = Set(items.compactMap { $0.isDownloadable ? $0.id : nil }) - } else { - selectedItems.removeAll() - } - } + func confirmDownloadFolder() { + let requests = items + .filter { $0.isDownloadable } + .compactMap { try? connectionService.createItemDownloadRequest($0) } - @MainActor - func onDownloadTapped() { - let downloadItems = selectedItems.compactMap { id in - items.first(where: { $0.id == id && $0.isDownloadable }) - } - guard !downloadItems.isEmpty else { return } - var requests = [URLRequest]() - for item in downloadItems { - do { - let request = try connectionService.createItemDownloadRequest(item) - requests.append(request) - } catch { - self.error = error - } + guard !requests.isEmpty else { + self.error = BookPlayerError.runtimeError("download_prepare_error".localized) + return } - guard !requests.isEmpty else { return } - singleFileDownloadService.handleDownload(requests) + + singleFileDownloadService.handleDownload(requests, folderName: navigationTitle) navigation.dismiss?() } - @MainActor func onDownloadFolderTapped() {} - @MainActor func confirmDownloadFolder() {} - private func applySearch() { let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) if query.isEmpty { @@ -672,103 +737,19 @@ final class JellyfinNarratorBooksViewModel: IntegrationLibraryViewModelProtocol, } totalItems = items.count } -} - -// MARK: - Authors List ViewModel - -@MainActor -final class JellyfinAuthorsListViewModel: IntegrationLibraryViewModelProtocol, BPLogger { - let parentID: String? - - var navigation: BPNavigation - let navigationTitle: String - - @AppStorage(Constants.UserDefaults.jellyfinLibraryLayout) - var layout: IntegrationLayout.Options = .list - - @AppStorage(Constants.UserDefaults.jellyfinLibraryLayoutSortBy) - var sortBy: JellyfinLayout.SortBy = .name - - @Published var searchQuery = "" - @Published var items: [JellyfinLibraryItem] = [] - @Published var totalItems = Int.max - @Published var error: Error? - - @Published var editMode: EditMode = .inactive - @Published var selectedItems: Set = [] - @Published var showingDownloadConfirmation = false - - var isSearchable: Bool { true } - - let connectionService: JellyfinConnectionService - private let singleFileDownloadService: SingleFileDownloadService - private var fetchTask: Task<(), any Error>? - private var allItems: [JellyfinLibraryItem] = [] - private var disposeBag = Set() - - init( - parentID: String?, - connectionService: JellyfinConnectionService, - singleFileDownloadService: SingleFileDownloadService, - navigation: BPNavigation, - navigationTitle: String - ) { - self.parentID = parentID - self.connectionService = connectionService - self.singleFileDownloadService = singleFileDownloadService - self.navigation = navigation - self.navigationTitle = navigationTitle - - $searchQuery - .debounce(for: .milliseconds(350), scheduler: RunLoop.main) - .removeDuplicates() - .dropFirst() - .sink { [weak self] _ in self?.applyLocalSearch() } - .store(in: &disposeBag) - } - - func fetchInitialItems() { - guard items.isEmpty, fetchTask == nil else { return } - fetchTask = Task { @MainActor in - defer { self.fetchTask = nil } - do { - let (items, _) = try await connectionService.fetchAlbumArtists(parentID: parentID) - self.allItems = items - applyLocalSearch() - } catch is CancellationError { - } catch { - self.error = error - } - } - } - - func fetchMoreItemsIfNeeded(currentItem: JellyfinLibraryItem) {} - func cancelFetchItems() { fetchTask?.cancel(); fetchTask = nil } - - func destination(for item: JellyfinLibraryItem) -> JellyfinLibraryLevelData? { - guard item.kind == .author else { return nil } - return .authorBooks(authorID: item.id, authorName: item.name, parentID: parentID) + + @MainActor + func goToSubscribe() { + navigation.showingSubscribe = true } - @MainActor func handleDoneAction() {} - @MainActor func onEditToggleSelectTapped() {} - @MainActor func onSelectTapped(for item: JellyfinLibraryItem) {} - @MainActor func onSelectAllTapped() {} - @MainActor func onDownloadTapped() {} - @MainActor func onDownloadFolderTapped() {} - @MainActor func confirmDownloadFolder() {} - - private func applyLocalSearch() { - let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) - items = query.isEmpty ? allItems : allItems.filter { $0.name.localizedCaseInsensitiveContains(query) } - totalItems = items.count - } } -// MARK: - Narrators List ViewModel +// MARK: - Persons List ViewModel (authors / narrators) @MainActor -final class JellyfinNarratorsListViewModel: IntegrationLibraryViewModelProtocol, BPLogger { +final class JellyfinPersonsListViewModel: IntegrationLibraryViewModelProtocol, BPLogger { + let role: JellyfinPersonRole let parentID: String? var navigation: BPNavigation @@ -787,26 +768,39 @@ final class JellyfinNarratorsListViewModel: IntegrationLibraryViewModelProtocol, @Published var editMode: EditMode = .inactive @Published var selectedItems: Set = [] - @Published var showingDownloadConfirmation = false + // showingDownloadConfirmation is deliberately NOT stored here — the protocol's no-op + // default makes the confirmation structurally unpresentable on a list of people var isSearchable: Bool { true } + /// People aren't importable: every editing action on this screen is a no-op, so the + /// toolbar must not offer Select/Download over a list of authors (ABS opts out the + /// same way for its `.entities` sources). + var allowsEditing: Bool { false } + /// `JellyfinLibraryView.sortPickerContent` only builds a picker for + /// `JellyfinLibraryViewModel`, so leaving this on renders an empty section. + var showsSortPreferences: Bool { false } let connectionService: JellyfinConnectionService + var accountService: AccountService private let singleFileDownloadService: SingleFileDownloadService private var fetchTask: Task<(), any Error>? private var allItems: [JellyfinLibraryItem] = [] private var disposeBag = Set() init( + role: JellyfinPersonRole, parentID: String?, connectionService: JellyfinConnectionService, singleFileDownloadService: SingleFileDownloadService, + accountService: AccountService, navigation: BPNavigation, navigationTitle: String ) { + self.role = role self.parentID = parentID self.connectionService = connectionService self.singleFileDownloadService = singleFileDownloadService + self.accountService = accountService self.navigation = navigation self.navigationTitle = navigationTitle @@ -823,7 +817,14 @@ final class JellyfinNarratorsListViewModel: IntegrationLibraryViewModelProtocol, fetchTask = Task { @MainActor in defer { self.fetchTask = nil } do { - let (items, _) = try await connectionService.fetchNarrators(parentID: parentID) + let result: ([JellyfinLibraryItem], Int) + switch role { + case .author: + result = try await connectionService.fetchAlbumArtists(parentID: parentID) + case .narrator: + result = try await connectionService.fetchNarrators(parentID: parentID) + } + let items = result.0 self.allItems = items applyLocalSearch() } catch is CancellationError { @@ -837,8 +838,14 @@ final class JellyfinNarratorsListViewModel: IntegrationLibraryViewModelProtocol, func cancelFetchItems() { fetchTask?.cancel(); fetchTask = nil } func destination(for item: JellyfinLibraryItem) -> JellyfinLibraryLevelData? { - guard item.kind == .narrator else { return nil } - return .narratorBooks(personID: item.id, personName: item.name, parentID: parentID) + switch (role, item.kind) { + case (.author, .author): + return .authorBooks(authorID: item.id, authorName: item.name, parentID: parentID) + case (.narrator, .narrator): + return .narratorBooks(personID: item.id, personName: item.name, parentID: parentID) + default: + return nil + } } @MainActor func handleDoneAction() {} @@ -848,10 +855,18 @@ final class JellyfinNarratorsListViewModel: IntegrationLibraryViewModelProtocol, @MainActor func onDownloadTapped() {} @MainActor func onDownloadFolderTapped() {} @MainActor func confirmDownloadFolder() {} + @Published var pendingImportBatch: ExternalImportBatch? + @MainActor func onStreamTapped(useSelectedItems: Bool) {} + @MainActor func confirmExternalImport(_ resources: [SimpleExternalResource]) {} private func applyLocalSearch() { let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) items = query.isEmpty ? allItems : allItems.filter { $0.name.localizedCaseInsensitiveContains(query) } totalItems = items.count } + + @MainActor + func goToSubscribe() { + navigation.showingSubscribe = true + } } diff --git a/BookPlayer/Library/ItemDetails/ItemDetailsExternalResourceSectionView.swift b/BookPlayer/Library/ItemDetails/ItemDetailsExternalResourceSectionView.swift new file mode 100644 index 000000000..1c2358d62 --- /dev/null +++ b/BookPlayer/Library/ItemDetails/ItemDetailsExternalResourceSectionView.swift @@ -0,0 +1,65 @@ +// +// ItemDetailsExternalResourceSectionView.swift +// BookPlayer +// +// Created by Gianni Carlo on 7/9/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import SwiftUI + +struct ItemDetailsExternalResourceSectionView: View { + let externalResources: [SimpleExternalResource] + /// Keyed by providerId; resolved by ItemDetailsViewModel off the main thread — + /// this view renders data and holds no keychain dependency. + let resolvedHosts: [String: String] + + @EnvironmentObject private var theme: ThemeViewModel + + var body: some View { + ThemedSection { + // Sync-created resources never populate the numeric id (all default to 0), + // so keying on Identifiable collides for multi-resource items; providerId + // is the stable key (same pattern as BookView). + ForEach(externalResources, id: \.providerId) { resource in + VStack(alignment: .leading, spacing: Spacing.S1) { + HStack { + Text("provider_title") + .bold() + Spacer() + Text(resource.providerName.capitalized) + .lineLimit(1) + } + + HStack { + Text("provider_id_title") + .bold() + Spacer() + Text(resource.providerId) + .lineLimit(1) + } + + if let hostId = resource.hostId, !hostId.isEmpty { + HStack { + Text("host_title") + .bold() + Spacer() + // The raw hostId is what resolution itself falls back to for a host + // with no saved connection, so showing it until (or instead of) a + // resolved URL keeps the field from ever rendering empty. + Text(resolvedHosts[resource.providerId] ?? hostId) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + .bpFont(.body) + .padding(.vertical, Spacing.S1) + } + } header: { + Text("external_resources_title") + } + } + +} diff --git a/BookPlayer/Library/ItemDetails/ItemDetailsView.swift b/BookPlayer/Library/ItemDetails/ItemDetailsView.swift index 0889ee452..dc197b8c4 100644 --- a/BookPlayer/Library/ItemDetails/ItemDetailsView.swift +++ b/BookPlayer/Library/ItemDetails/ItemDetailsView.swift @@ -53,6 +53,20 @@ struct ItemDetailsView: View { progress: viewModel.progress, lastPlayedDate: viewModel.lastPlayedDate ) + + // The gate lives HERE: the view renders unconditional content, the caller + // decides presence. Hardcover-only items have no hosted resources, so an item + // linked to Hardcover alone shows just the section above. + let hostedResources = viewModel.hostedExternalResources + if !hostedResources.isEmpty { + ItemDetailsExternalResourceSectionView( + externalResources: hostedResources, + resolvedHosts: viewModel.resolvedExternalHosts + ) + } + } + .task { + await viewModel.load() } .onChange(of: viewModel.selectedImage) { viewModel.artworkIsUpdated = true @@ -128,3 +142,4 @@ struct ItemDetailsView: View { .applyListStyle(with: theme, background: theme.systemGroupedBackgroundColor) } } + diff --git a/BookPlayer/Library/ItemDetails/ItemDetailsViewModel.swift b/BookPlayer/Library/ItemDetails/ItemDetailsViewModel.swift index fdeb1f5f9..dec122dc1 100644 --- a/BookPlayer/Library/ItemDetails/ItemDetailsViewModel.swift +++ b/BookPlayer/Library/ItemDetails/ItemDetailsViewModel.swift @@ -33,6 +33,10 @@ final class ItemDetailsViewModel: ObservableObject { let listState: ListStateManager private var hardcoverBook: SimpleHardcoverBook? + /// Guards `load()` so a re-appear doesn't refetch. Meaningful only because the view owns + /// this model with @StateObject — under the old per-render recreation it would have been + /// wiped along with the instance. + private var didLoad = false /// File name @Published var originalFileName: String @@ -56,11 +60,17 @@ final class ItemDetailsViewModel: ObservableObject { var showAuthor: Bool { item.type != .folder } @Published var hardcoverSectionViewModel: ItemDetailsHardcoverSectionView.Model? + /// Host display strings for the external-resources section, keyed by providerId. + /// Resolved off the main thread (keychain read + JSON decode per provider) — the + /// section view just renders this map. + @Published private(set) var resolvedExternalHosts: [String: String] = [:] init( item: SimpleLibraryItem, - libraryService: LibraryService, - syncService: SyncService, + // The protocol types the stored properties already use: the concrete ones here were + // the last thing keeping this model out of a test, since no mock could be injected. + libraryService: LibraryServiceProtocol, + syncService: SyncServiceProtocol, hardcoverService: HardcoverServiceProtocol, listState: ListStateManager ) { @@ -99,19 +109,129 @@ final class ItemDetailsViewModel: ObservableObject { hardcoverService: hardcoverService ) - Task { - if let item = await libraryService.getHardcoverBook(for: item.relativePath) { - hardcoverBook = item - hardcoverSectionViewModel?.pickerViewModel.selected = .init( - id: item.id, - artworkURL: item.artworkURL, - title: item.title, - author: item.author - ) - } + } + + /// Populate what has to be fetched. Driven by the view's `.task` rather than `init` so + /// SwiftUI owns the lifetime: the work is cancelled on dismissal — a hardcover fetch can + /// no longer land its stub repair after the sheet is gone — and a test can await it + /// instead of racing a task that construction started on its own. + /// + /// The call site deliberately passes no `id:`. `item` is fixed at construction, so nothing + /// should re-trigger this, and keying on the model's ObjectIdentifier would re-fire the + /// whole load every time the model were recreated. + @MainActor + func load() async { + guard !didLoad else { return } + didLoad = true + + // Hosts first: it is a local keychain read feeding a row that has no loading state, + // while the hardcover path can await a network fetch (and shows a spinner for it) — + // the reverse order left the host field waiting on it. + await resolveExternalHosts() + await resolveHardcoverSelection() + + // A load cancelled midway (sheet dismissed during the fetch) has to stay retryable, or + // re-presenting the screen would skip it and leave the rows half-populated. + if Task.isCancelled { + didLoad = false } } + /// The resources the external-resources section renders: media-server links only, since + /// Hardcover has its own section with a book picker right above it. + var hostedExternalResources: [SimpleExternalResource] { + item.externalResources?.mediaServerResources ?? [] + } + + private func resolveExternalHosts() async { + let resources = hostedExternalResources + guard !resources.isEmpty else { return } + // Off-main: the section view used to do these reads synchronously on appear + let resolved = await Task.detached(priority: .utility) { + IntegrationHostResolver.hostDisplayStrings(for: resources, keychain: KeychainService()) + }.value + await MainActor.run { resolvedExternalHosts = resolved } + } + + /// Populate the Hardcover picker selection. Prefers the full local reference; otherwise + /// falls back to the hardcover external resource — showing the item's title right away, + /// then fetching the real book info from Hardcover by its providerId. + @MainActor + private func resolveHardcoverSelection() async { + let stored = await libraryService.getHardcoverBook(for: item.relativePath) + // Only a row WITH metadata short-circuits: updateHardcoverStatus persists a + // status-only stub (empty title) when a synced-down link crosses the reading + // threshold before this device ever opened details — that stub must not shadow + // the fetch-repair below, or the row renders blank forever. + if let book = stored, !book.title.isEmpty { + hardcoverBook = book + hardcoverSectionViewModel?.pickerViewModel.selected = .init( + id: book.id, + artworkURL: book.artworkURL, + title: book.title, + author: book.author + ) + return + } + + /// No full local reference — fall back to the hardcover external resource, if any + guard hardcoverSectionViewModel != nil else { return } + + let resources = await libraryService.getExternalResources(for: item.relativePath) + guard + let resource = resources.first(where: { + $0.providerName == ExternalResource.ProviderName.hardcover.rawValue + }), + let bookID = Int(resource.providerId) + else { return } + + /// Show at least the item's title so the row reflects a selection. The interim + /// carries the STUB's monotonic state when one exists — getBook() knows nothing + /// about user state, and the save flow reads userBookID off this property. + hardcoverBook = SimpleHardcoverBook( + id: bookID, + artworkURL: nil, + title: item.title, + author: item.details, + status: stored?.status ?? .local, + userBookID: stored?.userBookID + ) + hardcoverSectionViewModel?.pickerViewModel.selected = .init( + id: bookID, + artworkURL: nil, + title: item.title, + author: item.details + ) + + /// Fetch the real book info from Hardcover and update the selection + hardcoverSectionViewModel?.isFetchingBook = true + let fetched = try? await hardcoverService.getBook(id: bookID) + hardcoverSectionViewModel?.isFetchingBook = false + + /// Discard the result if the selection was swapped or unlinked while fetching + guard + let fetched, + hardcoverSectionViewModel?.pickerViewModel.selected?.id == bookID + else { return } + + if let stub = stored { + // Repair the stub ONCE (metadata from Hardcover, state from the stub) so the + // row renders correctly everywhere without re-fetching per details visit. + let merged = stub.repairingMetadata(from: fetched) + await libraryService.setHardcoverBook(merged, for: item.relativePath) + hardcoverBook = merged + } else { + // Never linked locally: display-only, matching the pre-existing behavior. + hardcoverBook = fetched + } + hardcoverSectionViewModel?.pickerViewModel.selected = .init( + id: fetched.id, + artworkURL: fetched.artworkURL, + title: fetched.title, + author: fetched.author + ) + } + func handleSaveAction(_ loadingState: LoadingOverlayState, success: @escaping () -> Void) { Task { @MainActor in loadingState.show = true diff --git a/BookPlayer/Library/ItemList/ItemListView+Alerts.swift b/BookPlayer/Library/ItemList/ItemListView+Alerts.swift index 18a3add9a..3b186cc72 100644 --- a/BookPlayer/Library/ItemList/ItemListView+Alerts.swift +++ b/BookPlayer/Library/ItemList/ItemListView+Alerts.swift @@ -217,8 +217,10 @@ extension ItemListView { func alertTitle(for alert: ItemListAlert) -> String { switch alert { - case .queuedTasks: - return "sync_tasks_inprogress_alert_title".localized + case .queuedTasks(let paused): + return paused + ? "sync_paused_alert_title".localized + : "sync_tasks_inprogress_alert_title".localized case .importCompletion(let parameters): let filesCount = parameters.itemIdentifiers.count return String.localizedStringWithFormat("import_alert_title".localized, filesCount) diff --git a/BookPlayer/Library/ItemList/ItemListView+Sheets.swift b/BookPlayer/Library/ItemList/ItemListView+Sheets.swift index ee9b21153..4a47fc45e 100644 --- a/BookPlayer/Library/ItemList/ItemListView+Sheets.swift +++ b/BookPlayer/Library/ItemList/ItemListView+Sheets.swift @@ -17,7 +17,10 @@ extension ItemListView { case .itemDetails(let item): itemDetailsSheet(for: item) case .queuedTasks: - QueuedSyncTasksView() + // Own stack so the title and the info button show inside the sheet + NavigationStack { + QueuedTasksView() + } case .foldersSelection: foldersSelectionSheet() case .libraryOptions: diff --git a/BookPlayer/Library/ItemList/ItemListView.swift b/BookPlayer/Library/ItemList/ItemListView.swift index fd21451ea..54be4fe92 100644 --- a/BookPlayer/Library/ItemList/ItemListView.swift +++ b/BookPlayer/Library/ItemList/ItemListView.swift @@ -30,10 +30,9 @@ struct ItemListView: View { @Environment(\.libraryService) var libraryService @Environment(\.accountService) private var accountService @Environment(\.syncService) var syncService + @Environment(\.syncQueueService) private var syncQueueService @Environment(\.hardcoverService) var hardcoverService @Environment(\.playerLoaderService) private var playerLoaderService - @Environment(\.jellyfinService) var jellyfinService - @Environment(\.audiobookshelfService) var audiobookshelfService @Environment(\.preferencesService) var preferencesService @Environment(\.listState) var listState @Environment(\.playerState) private var playerState @@ -43,6 +42,9 @@ struct ItemListView: View { @EnvironmentObject private var importManager: ImportManager @EnvironmentObject private var theme: ThemeViewModel + /// A parked task in the sync lane: the blocked-refresh alert says sync is paused + @State private var syncLanePaused = false + init(initModel: @escaping () -> ItemListViewModel) { self._model = .init(wrappedValue: initModel()) } @@ -193,7 +195,7 @@ struct ItemListView: View { do { try await model.refreshListState() } catch { - self.activeAlert = .queuedTasks + self.activeAlert = .queuedTasks(paused: syncLanePaused) } } .environment(\.playingItemParentPath, playingItemParentPath) @@ -201,6 +203,9 @@ struct ItemListView: View { } } } + .onReceive(syncQueueService.observeQueueCounts()) { counts in + syncLanePaused = counts.pausedCount(in: TaskQueueKey.sync) > 0 + } .onReceive( model.singleFileDownloadService.eventsPublisher .throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true) @@ -383,7 +388,7 @@ struct ItemListView: View { @ViewBuilder func addFilesOptions() -> some View { - Button("import_button", systemImage: "waveform") { + Button("import_button", systemImage: "square.and.arrow.down") { showDocumentPicker = true } Button("download_from_url_title", systemImage: "link") { @@ -435,35 +440,24 @@ struct ItemListView: View { /// `ToolbarSpacer(.fixed, ...)` render as one shared capsule. Without the /// fixed spacer (or with `.flexible`), each item gets its own pill — which is /// what the cascading `.tint(theme.linkColor)` from `LibraryRootView` was - /// producing previously. On iOS 18 we fall back to the legacy - /// `ToolbarItemGroup` layout since `ToolbarSpacer` isn't available. + /// producing previously. @ToolbarContentBuilder private func regularToolbarItems() -> some ToolbarContent { - if #available(iOS 26.0, *) { - if importOperationState.isOperationActive { - ToolbarItem(placement: .topBarTrailing) { - importOperationButton - } - ToolbarSpacer(.fixed, placement: .topBarTrailing) - } - + if importOperationState.isOperationActive { ToolbarItem(placement: .topBarTrailing) { - libraryOptionsButton + importOperationButton } - ToolbarSpacer(.fixed, placement: .topBarTrailing) + } - ToolbarItem(placement: .topBarTrailing) { - ellipsisMenu - } - } else { - ToolbarItemGroup(placement: .confirmationAction) { - if importOperationState.isOperationActive { - importOperationButton - } - libraryOptionsButton - ellipsisMenu - } + ToolbarItem(placement: .topBarTrailing) { + libraryOptionsButton + } + + ToolbarSpacer(.fixed, placement: .topBarTrailing) + + ToolbarItem(placement: .topBarTrailing) { + ellipsisMenu } } diff --git a/BookPlayer/Library/ItemList/ItemListViewModel.swift b/BookPlayer/Library/ItemList/ItemListViewModel.swift index bb51315f8..2371933cf 100644 --- a/BookPlayer/Library/ItemList/ItemListViewModel.swift +++ b/BookPlayer/Library/ItemList/ItemListViewModel.swift @@ -10,7 +10,7 @@ import BookPlayerKit import SwiftUI @MainActor -final class ItemListViewModel: ObservableObject { +final class ItemListViewModel: ObservableObject, BPLogger { let libraryNode: LibraryNode private let libraryService: LibraryService private let playbackService: PlaybackService @@ -20,10 +20,9 @@ final class ItemListViewModel: ObservableObject { private let loadingState: LoadingOverlayState private let listState: ListStateManager let singleFileDownloadService: SingleFileDownloadService - /// Reference to ongoing library fetch task var contentsFetchTask: Task<(), Error>? - + @Published var items: [SimpleLibraryItem] = [] @Published var isLoading: Bool = false @Published var canLoadMore: Bool = true @@ -371,6 +370,7 @@ final class ItemListViewModel: ObservableObject { isUnfinished: true ) + // loadPlayer's contract is a RELATIVE PATH — a uuid silently no-ops the lookup. return nextPlayableItem?.relativePath } } @@ -750,7 +750,7 @@ extension ItemListViewModel { if item.type == .folder || item.type == .bound { try DataManager.createBackingFolderIfNeeded(fileURL) } - + try await syncService.downloadRemoteFiles(for: item) loadingState.show = false } catch { diff --git a/BookPlayer/Library/ItemList/LibraryRootView.swift b/BookPlayer/Library/ItemList/LibraryRootView.swift index 06f808da9..3818ff9bf 100644 --- a/BookPlayer/Library/ItemList/LibraryRootView.swift +++ b/BookPlayer/Library/ItemList/LibraryRootView.swift @@ -36,6 +36,7 @@ struct LibraryRootView: View { @EnvironmentObject private var playerManager: PlayerManager @EnvironmentObject private var importManager: ImportManager + @EnvironmentObject private var externalImportEvents: ExternalImportEvents @EnvironmentObject private var singleFileDownloadService: SingleFileDownloadService @EnvironmentObject private var listSyncRefreshService: ListSyncRefreshService @@ -119,12 +120,17 @@ struct LibraryRootView: View { DispatchQueue.main.async { self.importOperationState.isOperationActive = false self.importOperationState.processingTitle = "" - self.handleOperationCompletion(operation.processedFiles, suggestedFolderName: operation.suggestedFolderName) + self.handleOperationCompletion(.local(files: operation.processedFiles), suggestedFolderName: operation.suggestedFolderName) } } importManager.start(operation) } + .onReceive(externalImportEvents.confirmedBatches) { externalResources in + Task { + self.handleOperationCompletion(.external(files: externalResources), suggestedFolderName: nil) + } + } } .tint(theme.linkColor) .environmentObject(theme) @@ -183,15 +189,30 @@ struct LibraryRootView: View { } } - func handleOperationCompletion(_ files: [URL], suggestedFolderName: String?) { - guard !files.isEmpty else { + func handleOperationCompletion(_ importSource: ImportSource, suggestedFolderName: String?) { + let filesCount: Int + switch importSource { + case .local(let files): + filesCount = files.count + case .external(let externals): + filesCount = externals.count + } + + guard filesCount > 0 else { return } Task { @MainActor in - let processedItems = await libraryService.insertItems(from: files) + let processedItems: [SimpleLibraryItem] + switch importSource { + case .local(let files): + processedItems = await libraryService.insertItems(from: files) + case .external(let externals): + processedItems = await libraryService.insertItems(fromResources: externals) + } + var itemIdentifiers = processedItems.map({ $0.relativePath }) - var itemIdentifiersPairs = processedItems.map({ LibraryItemRef(relativePath: $0.relativePath, uuid: $0.uuid) }) + let itemIdentifiersPairs = processedItems.map({ LibraryItemRef(relativePath: $0.relativePath, uuid: $0.uuid) }) do { await syncService.scheduleUpload(items: processedItems) /// Move imported files to current selected folder so the user can see them @@ -258,4 +279,4 @@ extension LibraryRootView { LibraryRootView { } showImport: { } -} \ No newline at end of file +} diff --git a/BookPlayer/Library/ItemList/Models/ItemListAlert.swift b/BookPlayer/Library/ItemList/Models/ItemListAlert.swift index 2272f2f05..acdf9448f 100644 --- a/BookPlayer/Library/ItemList/Models/ItemListAlert.swift +++ b/BookPlayer/Library/ItemList/Models/ItemListAlert.swift @@ -11,7 +11,9 @@ import Foundation /// Represents all possible alert types in ItemListView enum ItemListAlert: Identifiable, Equatable { - case queuedTasks + /// A refresh is blocked by queued sync tasks; `paused` when one of them is parked and + /// needs the user (Retry or Report) rather than just time + case queuedTasks(paused: Bool) case importCompletion(ImportOperationState.AlertParameters) case moveOptions case createFolder(type: SimpleItemType, placeholder: String) diff --git a/BookPlayer/Library/ItemList/Views/BookView.swift b/BookPlayer/Library/ItemList/Views/BookView.swift index e20ee2203..4ef4756e0 100644 --- a/BookPlayer/Library/ItemList/Views/BookView.swift +++ b/BookPlayer/Library/ItemList/Views/BookView.swift @@ -88,11 +88,21 @@ struct BookView: View { libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) let accountService = AccountService() accountService.setup(dataManager: dataManager) + let tasksDataManager = TasksDataManager() + let syncQueueService = SyncQueueService() + syncQueueService.setup( + libraryService: libraryService, + getAccessLevel: { accountService.getAccessLevel() }, + verifySyncEntitlement: { nil }, + tasksDataManager: tasksDataManager, + networkClient: NetworkClient(), + dataManager: dataManager + ) syncService.setup( isActive: true, libraryService: libraryService, accountService: accountService, - dataManager: dataManager + syncQueueService: syncQueueService ) return syncService diff --git a/BookPlayer/Library/ItemList/Views/DynamicAccessibilityLabel.swift b/BookPlayer/Library/ItemList/Views/DynamicAccessibilityLabel.swift index 45ffe3c22..445efa1c5 100644 --- a/BookPlayer/Library/ItemList/Views/DynamicAccessibilityLabel.swift +++ b/BookPlayer/Library/ItemList/Views/DynamicAccessibilityLabel.swift @@ -45,7 +45,8 @@ struct DynamicAccessibilityLabelModifier: ViewModifier { self._accessibilityLabel = State( initialValue: VoiceOverService.getAccessibilityLabel( for: item, - useOriginalFileName: initialUseOriginalFileName + useOriginalFileName: initialUseOriginalFileName, + includeSource: true ) ) } @@ -97,12 +98,14 @@ struct DynamicAccessibilityLabelModifier: ViewModifier { originalFileName: item.originalFileName, lastPlayDate: item.lastPlayDate, type: item.type, - uuid: item.uuid + uuid: item.uuid, + externalResources: item.externalResources ) accessibilityLabel = VoiceOverService.getAccessibilityLabel( for: updatedItem, - useOriginalFileName: useOriginalFileName + useOriginalFileName: useOriginalFileName, + includeSource: true ) } .onChange(of: useOriginalFileName) { @@ -113,7 +116,8 @@ struct DynamicAccessibilityLabelModifier: ViewModifier { // overwrite this with the current playback state. accessibilityLabel = VoiceOverService.getAccessibilityLabel( for: item, - useOriginalFileName: useOriginalFileName + useOriginalFileName: useOriginalFileName, + includeSource: true ) } .onChange(of: playerManager.currentItem?.relativePath) { oldPath, newPath in @@ -162,10 +166,12 @@ struct DynamicAccessibilityLabelModifier: ViewModifier { lastPlayDate: item.lastPlayDate, type: item.type, uuid: item.uuid, + externalResources: item.externalResources ) accessibilityLabel = VoiceOverService.getAccessibilityLabel( for: updatedItem, - useOriginalFileName: useOriginalFileName + useOriginalFileName: useOriginalFileName, + includeSource: true ) } } diff --git a/BookPlayer/Library/ItemList/Views/ItemArtworkView.swift b/BookPlayer/Library/ItemList/Views/ItemArtworkView.swift index 08aadfa8f..30ee50bc9 100644 --- a/BookPlayer/Library/ItemList/Views/ItemArtworkView.swift +++ b/BookPlayer/Library/ItemList/Views/ItemArtworkView.swift @@ -137,10 +137,7 @@ struct ItemArtworkView: View { ZStack(alignment: .bottomTrailing) { CornerTriangle() .fill(theme.systemGroupedBackgroundColor) - Image(systemName: "cloud") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 16) + downloadBadge .padding(1) .padding(.trailing, 2) .foregroundStyle(theme.linkColor) @@ -148,6 +145,51 @@ struct ItemArtworkView: View { .clipShape(CornerTriangle()) } } + + /// The server this row would stream from, or nil when it isn't a media-server item. + /// + /// `streamingResource` rather than the first media-server link, for two reasons. It is the + /// same value `downloadRemoteFiles` picks, so the badge always names the server the tap will + /// actually reach. And it is already Hardcover-filtered: the API's `markExternalSourceUploaded` + /// marks EVERY provider row of an item 'downloaded', so a dual-linked book's Hardcover row + /// passes a bare syncStatus check — which once aimed the download at a provider that has no + /// files at all. + private var streamingServer: ExternalResource.MediaServerProvider? { + item.externalResources?.streamingResource?.mediaServer + } + + /// What sits in the corner while the file isn't on the device. + /// + /// `cloud` is right for our own storage and wrong for a media server — the file is on the + /// user's machine, not ours, which is the distinction the media-server paywall copy makes a + /// point of. The provider's mark says the same thing the cloud does, and says whose. + /// + /// All three badges share the cloud's 14pt-wide box so they land on ONE axis. The width is + /// what centres them: a height-only frame lets each mark's own width decide where its centre + /// falls, and the marks differ enough (Jellyfin is square, Audiobookshelf is taller than + /// wide) that they sat 1.6pt and 2.9pt right of the cloud — visibly off against a cloud row + /// above. Inside a shared box each one centres itself, whatever its aspect ratio. + /// + /// The 3pt below buys what the cloud gets for free. `cloud` is a wide, low glyph, so fitting + /// it into a 14x16 box letterboxes it and leaves ~4.6pt of air under its ink; a mark that + /// fills its box lands flush on the corner's edge instead and reads as sitting lower. + /// + /// The heights themselves are per-provider — see `MediaServerProvider.badgeHeight`. + @ViewBuilder + private var downloadBadge: some View { + if let streamingServer { + Image(streamingServer.icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: streamingServer.badgeHeight) + .padding(.bottom, 3) + } else { + Image(systemName: "cloud") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 16) + } + } } /// Bottom-right angled triangle diff --git a/BookPlayer/MainView.swift b/BookPlayer/MainView.swift index 2e491c2c0..5f8a7349b 100644 --- a/BookPlayer/MainView.swift +++ b/BookPlayer/MainView.swift @@ -16,6 +16,9 @@ struct MainView: View { @State private var listState = ListStateManager() @StateObject private var theme = ThemeViewModel() @StateObject private var keyboardObserver = KeyboardObserver() + /// A wire between SwiftUI views that MainCoordinator never touches, so it's owned here. + /// Injected below, where the integration sheets and the library tab both inherit it. + @StateObject private var externalImportEvents = ExternalImportEvents() @Environment(\.libraryService) private var libraryService @Environment(\.playerState) private var playerState @Environment(\.syncService) private var syncService @@ -60,7 +63,7 @@ struct MainView: View { Tab("settings_title", systemImage: "gearshape") { SettingsView() } - if #available(iOS 26.0, *), UIDevice.current.userInterfaceIdiom == .phone { + if UIDevice.current.userInterfaceIdiom == .phone { Tab("search_title", systemImage: "magnifyingglass", role: .search) { SearchView { SearchViewModel(libraryService: libraryService) @@ -102,16 +105,47 @@ struct MainView: View { libraryService: libraryService, playbackService: playbackService, playerManager: playerManager, - syncService: syncService + syncService: syncService, ) } .presentationBackground(.clear) + .playbackAlerts(playerState: playerState, playerManager: playerManager, whenPlayerVisible: true) + } + // Posted by the failure alert's Media Servers button (PlaybackAlerts) when a book can't + // play because its media server isn't configured here. Lives at this level because both + // pieces of state it writes are owned above: the cover has to come down before the sheet + // can present, since the sheet is attached underneath it. + .onReceive(NotificationCenter.default.publisher(for: .showMediaServers)) { _ in + playerState.isShowingPlayer = false + listState.activeIntegrationSheet = .mediaServers + } + // Sibling copy (see PlaybackAlerts): playback also starts with the player CLOSED — + // mini-player, CarPlay, remote commands, last-book restore — and the copy inside the cover + // isn't in the hierarchy then, silently dropping the prompt. + .playbackAlerts(playerState: playerState, playerManager: playerManager, whenPlayerVisible: false) + // A prompt belongs to the context that raised it and is NOT handed over when the player + // opens or closes — a deliberate product decision, not a workaround. The two-copy gate + // exists so exactly one copy can present, not so a prompt can migrate: a question the user + // was asked against a closed player, re-appearing over an open one after something else + // (a widget, an intent, Siri) opened it, has lost the context that made it make sense. + // Dropping it is the conservative half of that trade, and the alert is re-raised by the + // next attempt anyway. + .onChange(of: playerState.isShowingPlayer) { + playerState.clearPrompts() + } + // A failure raised while backgrounded outlives the attempt that caused it, so playback + // succeeding in the meantime has to retire it — otherwise coming back to the app shows an + // error for something that is playing right now. Only the failure: `.bookPlayed` is what + // drives the resume prompt in the first place (ExternalProgressService subscribes to it). + .onReceive(NotificationCenter.default.publisher(for: .bookPlayed)) { _ in + playerState.pendingFailure = nil } .accessibilityAction(.magicTap) { playerManager.playPause() } .environment(\.tabBarContentHeight, tabBarContentHeight) .environmentObject(theme) + .environmentObject(externalImportEvents) .environment(\.listState, listState) .tint(theme.linkColor) .onChange(of: scheme) { diff --git a/BookPlayer/MediaServerIntegration/BPNavigation.swift b/BookPlayer/MediaServerIntegration/BPNavigation.swift index 71322f502..4755e3de3 100644 --- a/BookPlayer/MediaServerIntegration/BPNavigation.swift +++ b/BookPlayer/MediaServerIntegration/BPNavigation.swift @@ -13,6 +13,10 @@ final class BPNavigation: ObservableObject { var dismiss: (() -> Void)? @Published var path = NavigationPath() + /// Drives the subscribe flow. A sheet rather than a pushed destination, matching how + /// every other paywall in the app is presented (SettingsView, AccountView) and keeping + /// it clear of the edit-mode bottom toolbar. + @Published var showingSubscribe = false nonisolated init() {} } diff --git a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionFlowView.swift b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionFlowView.swift index acc36bd57..232a47ebe 100644 --- a/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionFlowView.swift +++ b/BookPlayer/MediaServerIntegration/Connection Screen/IntegrationConnectionFlowView.swift @@ -275,14 +275,12 @@ struct IntegrationAddressScreen: Vie IntegrationCustomHeadersSectionView(customHeaders: $viewModel.form.customHeaders) IntegrationFlowButtonsSection { - IntegrationFlowPrimaryButton( - title: "integration_connect_button".localized, - isDisabled: address.url == nil || isLoading - ) { + PrimaryButton(text: "integration_connect_button".localized) { hideKeyboard() viewModel.form.serverUrl = address.urlString ?? "" onConnect() } + .disabled(address.url == nil || isLoading) } } .scrollDismissesKeyboard(.immediately) @@ -355,9 +353,10 @@ struct IntegrationMethodScreen: View } } IntegrationFlowButtonsSection { - IntegrationFlowPrimaryButton(title: primaryTitle, isDisabled: isLoading) { + PrimaryButton(text: primaryTitle) { onStartAlternative() } + .disabled(isLoading) if viewModel.supportsPasswordSignIn { Button { viewModel.flowPath.append(.password) @@ -418,11 +417,8 @@ struct IntegrationPasswordScreen: Vi onCommit: onSignIn ) IntegrationFlowButtonsSection { - IntegrationFlowPrimaryButton( - title: "integration_sign_in_button".localized, - isDisabled: viewModel.form.username.isEmpty || viewModel.form.password.isEmpty || isLoading, - action: onSignIn - ) + PrimaryButton(text: "integration_sign_in_button".localized, action: onSignIn) + .disabled(viewModel.form.username.isEmpty || viewModel.form.password.isEmpty || isLoading) } } .scrollDismissesKeyboard(.immediately) @@ -525,25 +521,3 @@ struct IntegrationFlowButtonsSection: View { } } } - -/// The flow's primary action — filled, full width, directly after the screen's content. -struct IntegrationFlowPrimaryButton: View { - let title: String - var isDisabled: Bool = false - var action: () -> Void - - @EnvironmentObject var theme: ThemeViewModel - - var body: some View { - Button(action: action) { - Text(title) - .bpFont(.headline) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(theme.linkColor) - .foregroundColor(.white) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - .disabledWithOpacity(isDisabled) - } -} diff --git a/BookPlayer/MediaServerIntegration/IntegrationDetailsViewModelProtocol.swift b/BookPlayer/MediaServerIntegration/IntegrationDetailsViewModelProtocol.swift deleted file mode 100644 index 88cfdf64a..000000000 --- a/BookPlayer/MediaServerIntegration/IntegrationDetailsViewModelProtocol.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// IntegrationDetailsViewModelProtocol.swift -// BookPlayer -// -// Created by Gianni Carlo on 4/5/26. -// Copyright © 2026 BookPlayer LLC. All rights reserved. -// - -import Foundation - -protocol IntegrationDetailsDataProtocol { - var artist: String? { get } - var filePath: String? { get } - var overview: String? { get } - var runtimeString: String { get } - var fileSizeString: String { get } - var genres: [String]? { get } - var tags: [String]? { get } - - // Optional — AudiobookShelf-specific, with defaults - var narrator: String? { get } - var seriesEntries: [IntegrationSeriesEntry] { get } -} - -extension IntegrationDetailsDataProtocol { - var narrator: String? { nil } - var seriesEntries: [IntegrationSeriesEntry] { [] } -} - -struct IntegrationSeriesEntry: Identifiable, Hashable { - let id: String - let name: String - let sequence: String? -} - -@MainActor -protocol IntegrationDetailsViewModelProtocol: ObservableObject { - associatedtype Item: IntegrationLibraryItemProtocol - associatedtype Details: IntegrationDetailsDataProtocol - - var item: Item { get } - var details: Details? { get } - var error: Error? { get set } - - func fetchData() - func cancelFetchData() - func beginDownloadAudiobook(_ item: Item) throws -} diff --git a/BookPlayer/MediaServerIntegration/IntegrationLibraryItemProtocol.swift b/BookPlayer/MediaServerIntegration/IntegrationLibraryItemProtocol.swift deleted file mode 100644 index 11065f95a..000000000 --- a/BookPlayer/MediaServerIntegration/IntegrationLibraryItemProtocol.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// IntegrationLibraryItemProtocol.swift -// BookPlayer -// -// Created by Gianni Carlo on 4/5/26. -// Copyright © 2026 BookPlayer LLC. All rights reserved. -// - -import Foundation - -protocol IntegrationLibraryItemProtocol: Identifiable, Hashable { - var id: String { get } - var displayName: String { get } - var isDownloadable: Bool { get } - var isNavigable: Bool { get } - var placeholderImageName: String { get } -} diff --git a/BookPlayer/MediaServerIntegration/IntegrationLibraryViewModelProtocol.swift b/BookPlayer/MediaServerIntegration/IntegrationLibraryViewModelProtocol.swift index 9d0a6157b..1e03ae80b 100644 --- a/BookPlayer/MediaServerIntegration/IntegrationLibraryViewModelProtocol.swift +++ b/BookPlayer/MediaServerIntegration/IntegrationLibraryViewModelProtocol.swift @@ -7,6 +7,7 @@ // import SwiftUI +import BookPlayerKit enum IntegrationLayout { enum Options: String { @@ -29,16 +30,31 @@ protocol IntegrationLibraryViewModelProtocol: ObservableObject { var editMode: EditMode { get set } var selectedItems: Set { get set } - + /// Drives the whole-level download confirmation. List-only screens inherit the + /// no-op default below so the dialog can never present over them. + var showingDownloadConfirmation: Bool { get set } + + var accountService: AccountService { get set } + var searchQuery: String { get set } var isSearchable: Bool { get } + /// True while the whole-level download is resolving its request list. Drives both the + /// progress overlay and the disabled state: the loading overlay is a small centred box, + /// so it only absorbs taps that land on it and cannot guard the toolbar button. The + /// gating has to be explicit. + var isPreparingDownload: Bool { get } + + /// Count shown in the whole-level download confirmation. Declared here, not only in the + /// extension, so a conformer whose download reaches past the loaded page can override it + /// — an extension-only member would dispatch statically through `Model` and ignore them. + var downloadableItemCount: Int { get } + // Feature flags (defaults provided) var isGridEnabled: Bool { get } var showsLayoutPreferences: Bool { get } var showsSortPreferences: Bool { get } var allowsEditing: Bool { get } - var showingDownloadConfirmation: Bool { get set } func fetchInitialItems() func fetchMoreItemsIfNeeded(currentItem: Item) @@ -49,12 +65,31 @@ protocol IntegrationLibraryViewModelProtocol: ObservableObject { @MainActor func onEditToggleSelectTapped() @MainActor func onSelectTapped(for item: Item) @MainActor func onSelectAllTapped() + /// Stream: virtual-import the level or the selection when the account carries the + /// entitlement, otherwise open the subscribe flow. Never downloads. + @MainActor func onStreamTapped(useSelectedItems: Bool) + /// Staged virtual-import selection awaiting confirmation (`.sheet(item:)`); + /// nil on the list-only screens that never import. + var pendingImportBatch: ExternalImportBatch? { get set } + /// Hands the confirmed (possibly edited) selection to the import bus. + @MainActor func confirmExternalImport(_ resources: [SimpleExternalResource]) @MainActor func onDownloadTapped() @MainActor func onDownloadFolderTapped() @MainActor func confirmDownloadFolder() + @MainActor func goToSubscribe() } extension IntegrationLibraryViewModelProtocol { + /// Count for the whole-level download confirmation — what will actually download, + /// after filtering. Never `totalItems`, which stays `Int.max` until a fetch resolves + /// and would render as 9223372036854775807 in the dialog. + var downloadableItemCount: Int { + items.filter { $0.isDownloadable }.count + } + + /// Only the Jellyfin folder screen resolves anything before confirming. + var isPreparingDownload: Bool { false } + var isGridEnabled: Bool { true } var showsLayoutPreferences: Bool { true } var showsSortPreferences: Bool { true } @@ -63,6 +98,66 @@ extension IntegrationLibraryViewModelProtocol { get { false } set {} } - func onDownloadFolderTapped() {} - func confirmDownloadFolder() {} +} + +// MARK: - Virtual import pipeline + +/// What hydration has to prove about a selected item before it can be imported: a REAL +/// file extension (never guessed) and a length the server actually measured. +/// +/// The duration is not cosmetic. It is the only source for an external row's +/// `book.duration` — nothing opens the file, so nothing can measure it later — and +/// `loadChapterOperation` refuses to start playback on a chapter whose duration is 0, +/// silently. An item imported without one is a row that can never play. +struct HydratedItem { + let fileExtension: String + let duration: TimeInterval + /// Whatever chapter list the same payload carried. Optional to an import — an item with + /// none is still perfectly playable as a single chapter. + let chapters: [ChapterMetadata] + + /// Both pieces or nothing: a nil result leaves the id out of the hydration map, which + /// is the pipeline's existing skip contract. + init?(fileExtension: String?, duration: TimeInterval?, chapters: [ChapterMetadata] = []) { + guard + let fileExtension, + !fileExtension.isEmpty, + let duration, + duration > 0 + else { return nil } + + self.fileExtension = fileExtension + self.duration = duration + self.chapters = chapters + } +} + +/// Shared orchestration for virtual imports, used by every provider's bulk and +/// details path: hydrate the selection for the values an import can't be built without, +/// build the import payloads, and skip the items that have none. Callers own the +/// `isImporting` reentrancy guard, error surfacing, and staging the result as their +/// `pendingImportBatch`; the two closures carry the only provider-specific parts. +@MainActor +enum VirtualImportPipeline { + /// - Returns: the import payloads for every item that hydrated, in selection order — + /// the caller stages them as its `pendingImportBatch`. Empty means nothing in the + /// selection was importable (callers surface the `import_no_audio_files_alert`); + /// fewer than `items.count` means the remainder was skipped. The two skip causes — + /// no audio-file metadata, no server-measured length — are deliberately not + /// distinguished: both mean "this can't be streamed", and neither is actionable + /// per-item beyond that one message. + /// - Throws: hydration/network errors, for the caller's error state. + static func run( + items: [Item], + id: (Item) -> String, + hydrate: ([String]) async throws -> [String: HydratedItem], + buildResource: (Item, HydratedItem) -> SimpleExternalResource + ) async throws -> [SimpleExternalResource] { + guard !items.isEmpty else { return [] } + + let hydratedByID = try await hydrate(items.map(id)) + return items.compactMap { item in + hydratedByID[id(item)].map { buildResource(item, $0) } + } + } } diff --git a/BookPlayer/MediaServerIntegration/Library Screen/Details/IntegrationAudiobookDetailsView.swift b/BookPlayer/MediaServerIntegration/Library Screen/Details/IntegrationAudiobookDetailsView.swift index bfac49881..523dd53b4 100644 --- a/BookPlayer/MediaServerIntegration/Library Screen/Details/IntegrationAudiobookDetailsView.swift +++ b/BookPlayer/MediaServerIntegration/Library Screen/Details/IntegrationAudiobookDetailsView.swift @@ -57,7 +57,7 @@ struct IntegrationAudiobookDetailsView< } if let narrator = viewModel.details?.narrator, !narrator.isEmpty { - Text("Narrated by \(narrator)") + Text(String(format: "audiobook_details_narrator_label".localized, narrator)) .bpFont(.subheadline) .foregroundStyle(theme.secondaryColor) .lineLimit(1) @@ -75,56 +75,49 @@ struct IntegrationAudiobookDetailsView< .bpFont(.caption) } - Button { - do { - try viewModel.beginDownloadAudiobook(viewModel.item) - onDownloadTap() - } catch { - viewModel.error = error - } - } label: { - HStack { - Image(systemName: "square.and.arrow.down") - Text("download_title".localized) - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding() - .foregroundStyle(theme.systemBackgroundColor) - .background(theme.linkColor) - .cornerRadius(10) + // Both buttons always render: Stream sells the entitlement when it's missing, + // Download always downloads. The old `if allowStream || showSubscribeButton` + // was a tautology — showSubscribeButton was the exact negation of allowStream, + // so the else branch was unreachable and both it and showSubscribeButton are + // gone. The entitlement question lives inside the button instead: allowStream + // is hasStreamingEnabled() (anyone who has ever paid), and SynchronizeButton + // routes to goToSubscribe() when it is false. + HStack(spacing: 12) { + DownloadButton + SynchronizeButton } .padding(.horizontal) + .padding(.vertical, 12) if let details = viewModel.details { VStack { if let filePath = details.filePath { - DisclosureGroup("File Path", isExpanded: $isFilePathExpanded) { + DisclosureGroup("file_path_title", isExpanded: $isFilePathExpanded) { Text(filePath) } .accessibilityHidden(true) } if let genres = details.genres, !genres.isEmpty { - DisclosureGroup("Genres", isExpanded: $isGenresExpanded) { + DisclosureGroup("genres_title", isExpanded: $isGenresExpanded) { IntegrationTagsView(tags: genres) } } if let overview = details.overview { - DisclosureGroup("Overview", isExpanded: $isOverviewExpanded) { + DisclosureGroup("overview_title", isExpanded: $isOverviewExpanded) { Text(overview) } } if let tags = details.tags, !tags.isEmpty { - DisclosureGroup("Tags", isExpanded: $isTagsExpanded) { + DisclosureGroup("tags_title", isExpanded: $isTagsExpanded) { IntegrationTagsView(tags: tags) } } if !details.seriesEntries.isEmpty { - DisclosureGroup("Series", isExpanded: .constant(true)) { + DisclosureGroup("series_title", isExpanded: .constant(true)) { VStack(alignment: .leading, spacing: 8) { ForEach(details.seriesEntries) { item in Text(item.name) @@ -140,12 +133,77 @@ struct IntegrationAudiobookDetailsView< .applyListStyle(with: theme, background: theme.systemBackgroundColor) .tint(theme.linkColor) .errorAlert(error: $viewModel.error) - .onAppear { + .task(id: ObjectIdentifier(viewModel)) { viewModel.fetchData() } + .sheet(item: $viewModel.pendingImportBatch) { batch in + ExternalImportView( + initModel: { + ExternalImportViewModel(batch: batch) { resources in + viewModel.confirmExternalImport(resources) + } + } + ) + .presentationBackground(.clear) + .environmentObject(theme) + } .onDisappear { viewModel.cancelFetchData() } .scrollIndicators(.hidden) } + + @ViewBuilder + private var DownloadButton: some View { + Button { + do { + try viewModel.beginDownloadAudiobook(viewModel.item) + onDownloadTap() + } catch { + viewModel.error = error + } + } label: { + Label("download_title", systemImage: "square.and.arrow.down") + } + /// The same style as Stream, in the secondary colours: sharing it means the two cannot + /// drift apart in height, radius or font the way hand-matched geometry does. + .buttonStyle( + PrimaryButtonStyle( + background: theme.tertiarySystemBackgroundColor, + foregroundStyle: theme.primaryColor + ) + ) + } + + @ViewBuilder + private var SynchronizeButton: some View { + Button { + if viewModel.allowStream { + Task { + do { + // No onDownloadTap() here: this stages pendingImportBatch for the + // confirmation sheet below, and dismissing the browser would tear that + // sheet down before it could present. confirmExternalImport deliberately + // keeps you in the browser afterwards. + try await self.viewModel.handleImportAudiobook(viewModel.item) + } catch { + viewModel.error = error + } + } + } else { + viewModel.goToSubscribe() + } + } label: { + HStack { + /// `waveform`, not a second downward arrow: this sat next to the download button + /// wearing `arrow.down.circle.dotted`, so the screen offered two download glyphs and + /// left you to guess. It also matches the streaming row on the paywall. + Image(systemName: "waveform") + Text("stream_button") + } + } + /// The app's shared primary treatment rather than a fourth hand-rolled one — height, + /// radius, font, pressed and disabled states all come from the style. + .buttonStyle(PrimaryButtonStyle(background: theme.linkColor, foregroundStyle: .white)) + } } diff --git a/BookPlayer/MediaServerIntegration/Library Screen/GridLayout/IntegrationLibraryGridView.swift b/BookPlayer/MediaServerIntegration/Library Screen/GridLayout/IntegrationLibraryGridView.swift index bc22889ce..7459e60b1 100644 --- a/BookPlayer/MediaServerIntegration/Library Screen/GridLayout/IntegrationLibraryGridView.swift +++ b/BookPlayer/MediaServerIntegration/Library Screen/GridLayout/IntegrationLibraryGridView.swift @@ -15,7 +15,7 @@ struct IntegrationLibraryGridView< @ObservedObject var viewModel: Model @ViewBuilder let cellContent: (Model.Item) -> CellContent - @ScaledMetric var accessabilityScale: CGFloat = 1 + @ScaledMetric var accessibilityScale: CGFloat = 1 private let itemMinSizeBase = CGSize(width: 100, height: 100) private let itemMaxSizeBase = CGSize(width: 250, height: 250) private let itemSpacingBase = 20.0 @@ -26,12 +26,12 @@ struct IntegrationLibraryGridView< minimum: itemMinSizeBase.width, maximum: itemMaxSizeBase.width ), - spacing: itemSpacingBase * accessabilityScale + spacing: itemSpacingBase * accessibilityScale )] } var body: some View { - LazyVGrid(columns: columns, spacing: itemSpacingBase * accessabilityScale) { + LazyVGrid(columns: columns, spacing: itemSpacingBase * accessibilityScale) { ForEach(viewModel.items, id: \.id) { item in cellContent(item) .accessibilityAddTraits(.isButton) diff --git a/BookPlayer/MediaServerIntegration/Library Screen/IntegrationLibraryView.swift b/BookPlayer/MediaServerIntegration/Library Screen/IntegrationLibraryView.swift index 929166fe6..24cca1208 100644 --- a/BookPlayer/MediaServerIntegration/Library Screen/IntegrationLibraryView.swift +++ b/BookPlayer/MediaServerIntegration/Library Screen/IntegrationLibraryView.swift @@ -45,7 +45,19 @@ struct IntegrationLibraryView< .scrollContentBackground(.hidden) } } + .sheet(item: $viewModel.pendingImportBatch) { batch in + ExternalImportView( + initModel: { + ExternalImportViewModel(batch: batch) { resources in + viewModel.confirmExternalImport(resources) + } + } + ) + .presentationBackground(.clear) + .environmentObject(theme) + } .scrollDismissesKeyboard(.interactively) + .loadingOverlay(viewModel.isPreparingDownload) .background(theme.systemBackgroundColor) .modifier(IntegrationSearchableModifier( isSearchable: viewModel.isSearchable, @@ -55,12 +67,10 @@ struct IntegrationLibraryView< .onAppear { viewModel.fetchInitialItems() } .onDisappear { viewModel.cancelFetchItems() } .errorAlert(error: $viewModel.error) - .environment(\.editMode, $viewModel.editMode) - .onChange(of: viewModel.editMode) { _, newValue in - tabEditing.wrappedValue = newValue.isEditing - } + // Whole-level download only: an explicit selection needs no second confirmation, + // and Stream is already confirmed downstream by ExternalImportView. .confirmationDialog( - "download_folder_confirmation_title".localized, + "download_title".localized, isPresented: $viewModel.showingDownloadConfirmation ) { Button("download_title".localized) { @@ -68,7 +78,16 @@ struct IntegrationLibraryView< } Button("cancel_button".localized, role: .cancel) {} } message: { - Text(String.localizedStringWithFormat("download_folder_confirmation_message".localized, viewModel.totalItems)) + Text( + String.localizedStringWithFormat( + "download_folder_confirmation_message".localized, + viewModel.downloadableItemCount + ) + ) + } + .environment(\.editMode, $viewModel.editMode) + .onChange(of: viewModel.editMode) { _, newValue in + tabEditing.wrappedValue = newValue.isEditing } .toolbar { ToolbarItem(placement: .principal) { @@ -99,9 +118,23 @@ struct IntegrationLibraryView< Button(action: viewModel.onEditToggleSelectTapped) { Label("select_title".localized, systemImage: "checkmark.circle") } + + // Both fire OUTSIDE edit mode, where the selection is empty — they + // always mean "everything at this level". + Button { + viewModel.onStreamTapped(useSelectedItems: false) + } label: { + Label("stream_button", systemImage: "waveform") + } + .disabled(viewModel.downloadableItemCount == 0) + Button(action: viewModel.onDownloadFolderTapped) { Label("download_title".localized, systemImage: "arrow.down.to.line") } + // Conservative: on a Jellyfin folder whose loaded page is all subfolders this + // reads 0 while the server has audiobooks, so Download is briefly unavailable + // until another page lands. A false negative — never the wrong action. + .disabled(viewModel.downloadableItemCount == 0 || viewModel.isPreparingDownload) } } @@ -120,9 +153,9 @@ struct IntegrationLibraryView< var layoutPreferences: some View { if viewModel.showsLayoutPreferences { ThemedSection { - Picker(selection: $viewModel.layout, label: Text("Layout options".localized)) { - Label("Grid".localized, systemImage: "square.grid.2x2").tag(IntegrationLayout.Options.grid) - Label("List".localized, systemImage: "list.bullet").tag(IntegrationLayout.Options.list) + Picker(selection: $viewModel.layout, label: Text("layout_options_title")) { + Label("layout_grid_option", systemImage: "square.grid.2x2").tag(IntegrationLayout.Options.grid) + Label("layout_list_option", systemImage: "list.bullet").tag(IntegrationLayout.Options.list) } } } @@ -141,8 +174,17 @@ struct IntegrationLibraryView< Spacer() + Button { + viewModel.onStreamTapped(useSelectedItems: true) + } label: { + Image(systemName: "waveform") + .accessibilityLabel("stream_button") + } + .disabled(viewModel.selectedItems.isEmpty) + Button(action: viewModel.onDownloadTapped) { Image(systemName: "arrow.down.to.line") + .accessibilityLabel("download_title") } .disabled(viewModel.selectedItems.isEmpty) } diff --git a/BookPlayer/MediaServerIntegration/MediaServersView.swift b/BookPlayer/MediaServerIntegration/MediaServersView.swift index e4dcad2ad..f30e5d37f 100644 --- a/BookPlayer/MediaServerIntegration/MediaServersView.swift +++ b/BookPlayer/MediaServerIntegration/MediaServersView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import BookPlayerKit /// Unified screen for media-server integrations. Has two presentation styles: /// diff --git a/BookPlayer/Player/PlayerLoaderService.swift b/BookPlayer/Player/PlayerLoaderService.swift index d4ad8356e..90cb1fb53 100644 --- a/BookPlayer/Player/PlayerLoaderService.swift +++ b/BookPlayer/Player/PlayerLoaderService.swift @@ -40,40 +40,51 @@ final class PlayerLoaderService: @unchecked Sendable { autoplay: Bool, recordAsLastBook: Bool = true ) async throws { - let fileURL = DataManager.getProcessedFolderURL().appendingPathComponent(relativePath) - + // The loader's contract is a RELATIVE PATH — every launch surface speaks paths (App + // Intents, CarPlay, search, watch, last-book restore, deep links). Resolution to the + // item (and its uuid) happens here, in one place. A path that no longer resolves + // (stale last-book restore, CarPlay/watch reference to a deleted item) must THROW — + // develop surfaced fileMissing here, and a silent return leaves the tap doing + // nothing with no explanation. + guard let libraryItem = self.libraryService.getSimpleItem(with: relativePath) + else { + throw BPPlayerError.fileMissing(relativePath: relativePath) + } + + let fileURL = DataManager.getProcessedFolderURL().appendingPathComponent(libraryItem.relativePath) + if syncService.isActive == false, - !FileManager.default.fileExists(atPath: fileURL.path) + libraryItem.externalResources?.isEmpty ?? true, + !FileManager.default.fileExists(atPath: fileURL.path) { - throw BPPlayerError.fileMissing(relativePath: relativePath) + throw BPPlayerError.fileMissing(relativePath: libraryItem.relativePath) } - - // Only load if loaded book is a different one + + // Only load if loaded book is a different one. Guard on a REAL uuid: two items + // carrying a placeholder/empty uuid would otherwise match as "already loaded" and + // the second tap would silently no-op (same rule as PlayableItem.id). if playerManager.hasLoadedBook() == true, - relativePath == playerManager.currentItem?.relativePath + Constants.isRealUuid(libraryItem.uuid), + libraryItem.uuid == playerManager.currentItem?.uuid { if autoplay { playerManager.play() } return } - - guard - let libraryItem = self.libraryService.getSimpleItem(with: relativePath) - else { return } - + /// If the selected item is a bound book, check that the contents are loaded if syncService.isActive == true, - libraryItem.type == .bound, - libraryService.getMaxItemsCount(at: relativePath) == 0 + libraryItem.type == .bound, + libraryService.getMaxItemsCount(at: libraryItem.relativePath) == 0 { - _ = try await syncService.syncListContents(at: relativePath) + _ = try await syncService.syncListContents(at: libraryItem.relativePath) } - + let item = try self.playbackService.getPlayableItem(from: libraryItem) - + playerManager.load(item, autoplay: autoplay) - + if recordAsLastBook { libraryService.setLibraryLastBook(with: item.relativePath) } diff --git a/BookPlayer/Player/PlayerManager.swift b/BookPlayer/Player/PlayerManager.swift index c2ab85f08..6bfee644e 100755 --- a/BookPlayer/Player/PlayerManager.swift +++ b/BookPlayer/Player/PlayerManager.swift @@ -18,6 +18,13 @@ import Sentry final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { private let libraryService: LibraryServiceProtocol private let playbackService: PlaybackServiceProtocol + /// Narrow read of the streaming entitlement (pro or lite), not the whole account + /// service — the media-servers shortcut is only actionable for a tier that can stream. + private let hasStreamingEnabled: () -> Bool + /// Hands a failure to whichever surface the user is on. Injected because both failure sites + /// are async and surface-agnostic — see `playbackFailure(for:title:message:)`. Always called + /// through `presentOnMain(for:title:message:)`, never directly. + private let presentFailure: (PlaybackFailure) -> Void private let syncService: SyncServiceProtocol private let speedService: SpeedServiceProtocol private let userActivityManager: UserActivityManager @@ -29,7 +36,11 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { private var fadeTimer: Timer? private var timeControlPassthroughPublisher = CurrentValueSubject(.paused) + /// Named + cancelled-before-rebind: bound on every player recreation (init, .failed, + /// mediaServicesWereReset) — a disposeBag entry would accumulate one sink per recreation. private var timeControlSubscription: AnyCancellable? + /// Named + cancelled-before-rebind: bound on EVERY chapter/item load — a disposeBag entry + /// leaks one KVO publisher (which retains its AVPlayerItem) per loaded chapter. private var playableChapterSubscription: AnyCancellable? private var isPlayingSubscription: AnyCancellable? /// Tracks the brief muted play used to claim Now Playing on CarPlay connect, so we can pause once it starts @@ -45,6 +56,7 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { /// Set when audio-session activation fails in the current process, so a later /// successful activation can tell whether recovery required an app relaunch. private var audioSessionFailedThisSession = false + private var canFetchExternalURL = true private var hasObserverRegistered = false private var observeStatus: Bool = false { didSet { @@ -78,7 +90,9 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { syncService: SyncServiceProtocol, speedService: SpeedServiceProtocol, shakeMotionService: ShakeMotionServiceProtocol, - widgetReloadService: WidgetReloadServiceProtocol + widgetReloadService: WidgetReloadServiceProtocol, + hasStreamingEnabled: @escaping () -> Bool, + presentFailure: @escaping (PlaybackFailure) -> Void ) { self.libraryService = libraryService self.playbackService = playbackService @@ -87,6 +101,8 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { self.userActivityManager = UserActivityManager(libraryService: libraryService) self.shakeMotionService = shakeMotionService self.widgetReloadService = widgetReloadService + self.hasStreamingEnabled = hasStreamingEnabled + self.presentFailure = presentFailure super.init() setupPlayerInstance() @@ -187,16 +203,19 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { @MainActor func loadRemoteURLAsset(for chapter: PlayableChapter, forceRefresh: Bool) async throws -> AVURLAsset { let fileURL: URL - if !forceRefresh, let chapterURL = chapter.remoteURL { fileURL = chapterURL } else { isFetchingRemoteURL = true + // For a bound book the chapter is a child file and `currentItem` is the parent: + // sending the parent's uuid would make the API return the parent row (no file + // URL) instead of this chapter, so resolve bound chapters by path only. + let itemUUID = currentItem?.isBoundBook == true ? nil : currentItem?.uuid fileURL = try await syncService - .getRemoteFileURLs(of: chapter.relativePath, for: currentItem?.uuid, type: .book)[0].url + .getRemoteFileURLs(of: chapter.relativePath, for: itemUUID, type: .book)[0].url isFetchingRemoteURL = false } @@ -250,16 +269,32 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { return asset } - func loadPlayerItem(for chapter: PlayableChapter, forceRefreshURL: Bool) async throws { + @MainActor + func loadPlayerItem(for chapter: PlayableChapter, forceRefreshURL: Bool) async throws -> PlayableChapter { let fileURL = DataManager.getProcessedFolderURL().appendingPathComponent(chapter.relativePath) + let isMissingLocally = !FileManager.default.fileExists(atPath: fileURL.path) let asset: AVURLAsset - if syncService.isActive, - !FileManager.default.fileExists(atPath: fileURL.path) - { + if isMissingLocally, hasStreamingEnabled(), let externalUrl = chapter.externalUrl { + // Media-server chapters ALWAYS stream from their external URL — a forced refresh + // must not reroute them to the cloud/S3 presign path. The entitlement is checked HERE + // rather than only at import: the rows outlive the subscription that created them, and + // the stream reaches the user's own server, so nothing server-side can gate it. + // No metadata is loaded here: + // the length comes from the server at import time (`VirtualImportPipeline` refuses + // an item without one), so there is nothing a round trip to the stream could add. + // `AVURLAssetHTTPHeaderFieldsKey` is undocumented, but it is the only way to attach + // the server's auth header short of an AVAssetResourceLoaderDelegate. + asset = AVURLAsset(url: externalUrl, options: [ + AVURLAssetPreferPreciseDurationAndTimingKey: false, + "AVURLAssetHTTPHeaderFieldsKey": chapter.externalHeaders + ]) + } else if isMissingLocally, syncService.isActive { asset = try await loadRemoteURLAsset(for: chapter, forceRefresh: forceRefreshURL) } else { + /// The file is on disk — or it is missing with no remote source, and AVFoundation + /// surfaces that as a player-item failure. asset = AVURLAsset(url: fileURL, options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]) } @@ -271,6 +306,8 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { self.playerItem = AVPlayerItem(asset: asset) self.playerItem?.audioTimePitchAlgorithm = .timeDomain + + return self.currentItem?.currentChapter ?? chapter } func load(_ item: PlayableItem, autoplay: Bool) { @@ -365,6 +402,69 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { SharedWidgetStore.store(item) } + /// Whether a failed load should offer the Media Servers shortcut: the tier can stream at + /// all, and going to Media Servers could actually fix this chapter's failure. Not private + /// so the entitlement wiring itself is testable. + func offersMediaServers(for chapter: PlayableChapter) -> Bool { + hasStreamingEnabled() && chapter.needsMediaServer() + } + + /// A failure described for whichever surface ends up showing it. Shared by both failure + /// sites so the reason and the offer can't drift between the metadata path and the + /// player-item path. + /// + /// This only DESCRIBES the failure. Presenting it is the arbiter's job: both call sites are + /// asynchronous — a detached metadata task and a KVO status callback — so by the time either + /// fires, whichever surface asked for playback is long gone from the stack. + /// + /// Not private so the reason derivation is testable without provoking a real AVFoundation + /// failure, same as `offersMediaServers(for:)`. + func playbackFailure( + for chapter: PlayableChapter?, + title: String, + message: String? + ) -> PlaybackFailure { + /// `needsMediaServer()` gates BOTH media-server reasons, because it is the only one of the + /// two predicates that checks the filesystem: `hasUnresolvedExternalHost` is a stored flag + /// that stays true on a DOWNLOADED book whose connection was later removed. Reading it + /// first would have blamed a missing server for a local file that simply won't open, and + /// reported a reason that disagrees with `canOfferMediaServers` on the same value. + let reason: PlaybackFailure.Reason + + if let chapter, chapter.needsMediaServer() { + reason = chapter.hasUnresolvedExternalHost ? .missingConnection : .streamUnavailable + } else { + reason = .other + } + + return PlaybackFailure( + reason: reason, + phoneTitle: title, + phoneMessage: message, + canOfferMediaServers: chapter.map(offersMediaServers(for:)) ?? false + ) + } + + /// The one way a failure leaves this class. Hops to main unconditionally, as the UIKit + /// presentation this replaced did: `PlayerManager` is not `@MainActor`, and the player-item + /// site is a raw KVO callback on `AVPlayerItem.status`, which AVFoundation does not promise + /// to deliver on the main thread — while every presenter writes `@Observable` state or puts + /// a template on screen. Keeping the hop here rather than in the injected closure means an + /// injector cannot forget it. + /// + /// It BUILDS the failure inside the hop, not just presents it: `playbackFailure` reaches + /// `hasStreamingEnabled()`, which reads `donationMade` through `AccountService.getAccount()` + /// — a fetch on the VIEW context. Constructing at the call site would run that fetch on + /// whatever thread KVO happened to use. The caller still resolves `chapter` itself, so the + /// snapshot is taken when the failure occurs rather than a turn later. + private func presentOnMain(for chapter: PlayableChapter?, title: String, message: String?) { + Task { @MainActor in + self.presentFailure( + self.playbackFailure(for: chapter, title: title, message: message) + ) + } + } + func loadChapterMetadata(_ chapter: PlayableChapter, autoplay: Bool? = nil, forceRefreshURL: Bool = false) { if let autoplay { playbackQueued = autoplay @@ -372,15 +472,19 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { loadChapterTask = Task { @MainActor [unowned self] in do { - try await self.loadPlayerItem(for: chapter, forceRefreshURL: forceRefreshURL) - self.loadChapterOperation(chapter) + let updatedChapter = try await self.loadPlayerItem(for: chapter, forceRefreshURL: forceRefreshURL) + self.loadChapterOperation(updatedChapter) } catch BookPlayerError.cancelledTask { /// Do nothing, as it was cancelled to load another item } catch { self.playbackQueued = nil self.isFetchingRemoteURL = nil self.observeStatus = false - self.showErrorAlert(title: "\("error_title".localized) Metadata", error.localizedDescription) + self.presentOnMain( + for: chapter, + title: "\("error_title".localized) Metadata", + message: error.localizedDescription + ) return } } @@ -389,6 +493,11 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { func loadChapterOperation(_ chapter: PlayableChapter) { self.queue.addOperation { // try loading the player + // `duration > 0` is load-bearing for PLAYBACK, not just hygiene: failing it posts + // `.bookReady loaded:false`, which only CarPlay and the watch observe, so the user + // sees the spinner clear and nothing happen. Media-server rows can't land here with + // a 0 duration — `HydratedItem` refuses to import one — and that is what lets the + // external branch of `loadPlayerItem` skip loading metadata off the stream. guard let playerItem = self.playerItem, chapter.duration > 0 @@ -407,6 +516,7 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { // Update UI on main thread DispatchQueue.main.async { self.isFetchingRemoteURL = nil + self.audioPlayer.replaceCurrentItem(with: playerItem) self.currentSpeed = self.speedService.getSpeed(relativePath: chapter.relativePath) @@ -844,6 +954,7 @@ extension PlayerManager { func prepareForPlayback(_ currentItem: PlayableItem) async -> Bool { /// Allow refetching remote URL if the action was initiating by the user canFetchRemoteURL = true + canFetchExternalURL = true guard let playerItem else { /// Check if the playbable item is in the process of being set @@ -1058,6 +1169,8 @@ extension PlayerManager { setNowPlayingBookTitle(chapter: currentItem.currentChapter) + // ExternalProgressService self-subscribes to this: the media-server progress pull no + // longer depends on which UI happens to hold the delegate slot. NotificationCenter.default.post(name: .bookPlayed, object: nil, userInfo: ["book": currentItem]) } } @@ -1152,10 +1265,23 @@ extension PlayerManager { { loadAndRefreshURL(item: currentItem) canFetchRemoteURL = false + } else if let currentItem, + currentItem.currentChapter.externalUrl != nil, + canFetchExternalURL { + /// One retry for a transient stream failure. The reload replays the SAME item, so + /// the URL and auth headers are identical — this recovers a blip, never a rotated + /// token. Re-authenticating and tapping the SAME book does not recover either: + /// `currentItem` survives the failure, so `PlayerLoaderService.loadPlayer` + /// short-circuits on the matching uuid straight to `play()`, and the item is never + /// rebuilt from the saved connection. That needs another book, or a relaunch. + loadAndRefreshURL(item: currentItem) + canFetchExternalURL = false } else { /// Avoid showing any alert if playback is not queued, this could be from the initial app launch /// where we preload the player with the last played item if playbackQueued == true { + let chapter = currentItem?.currentChapter + if let nsError = item.error as? NSError { let errorDescription = """ \(nsError.localizedDescription) @@ -1166,9 +1292,17 @@ extension PlayerManager { Additional Info \(nsError.userInfo) """ - showErrorAlert(title: "\("error_title".localized) \(nsError.code)", errorDescription) + presentOnMain( + for: chapter, + title: "\("error_title".localized) \(nsError.code)", + message: errorDescription + ) } else { - showErrorAlert(title: "error_title".localized, item.error?.localizedDescription) + presentOnMain( + for: chapter, + title: "error_title".localized, + message: item.error?.localizedDescription + ) } } @@ -1508,16 +1642,6 @@ extension PlayerManager { } } -extension PlayerManager { - private func showErrorAlert(title: String, _ message: String?) { - DispatchQueue.main.async { - WindowHelper.activeWindow?.rootViewController? - .getTopVisibleViewController()? - .showAlert(title, message: message) - } - } -} - // MARK: - Sleep timer extension PlayerManager { private func handleSleepTimerThresholdEvent() { diff --git a/BookPlayer/Player/PlayerManagerProtocol.swift b/BookPlayer/Player/PlayerManagerProtocol.swift index 7b7643090..0bf702695 100644 --- a/BookPlayer/Player/PlayerManagerProtocol.swift +++ b/BookPlayer/Player/PlayerManagerProtocol.swift @@ -13,7 +13,6 @@ #endif import Combine import Foundation - /// sourcery: AutoMockable public protocol PlayerManagerProtocol: AnyObject { var currentItem: PlayableItem? { get set } diff --git a/BookPlayer/Player/ViewModels/PlayerViewModel.swift b/BookPlayer/Player/ViewModels/PlayerViewModel.swift index d182f0d69..e32c06678 100644 --- a/BookPlayer/Player/ViewModels/PlayerViewModel.swift +++ b/BookPlayer/Player/ViewModels/PlayerViewModel.swift @@ -121,6 +121,9 @@ final class PlayerViewModel: ObservableObject { } func bindBookObservers() { + // Called on every player appear — drop the previous generation of sinks or each + // open appends another copy of every subscription below + disposeBag.removeAll() bindBookPlayingProgressEvents() bindNotificationSubscribers() bindBookSharedObservers() @@ -195,6 +198,7 @@ final class PlayerViewModel: ObservableObject { } func bindBookPlayingProgressEvents() { + self.playerManager.isPlayingPublisher() .removeDuplicates() .receive(on: DispatchQueue.main) diff --git a/BookPlayer/Player/Views/Chapters/ChaptersView.swift b/BookPlayer/Player/Views/Chapters/ChaptersView.swift index 3f6097905..eef4f78cd 100644 --- a/BookPlayer/Player/Views/Chapters/ChaptersView.swift +++ b/BookPlayer/Player/Views/Chapters/ChaptersView.swift @@ -142,6 +142,7 @@ extension ChaptersView { duration: 300, relativePath: "book1.m4b", remoteURL: nil, + externalURL: nil, index: 0 ) @@ -156,6 +157,7 @@ extension ChaptersView { duration: 300, relativePath: "book1.m4b", remoteURL: nil, + externalURL: nil, index: 1 ), ], diff --git a/BookPlayer/Profile/Account/AccountPasskeySectionView.swift b/BookPlayer/Profile/Account/AccountPasskeySectionView.swift index 9b7a30c8e..41b49083c 100644 --- a/BookPlayer/Profile/Account/AccountPasskeySectionView.swift +++ b/BookPlayer/Profile/Account/AccountPasskeySectionView.swift @@ -32,7 +32,7 @@ struct AccountPasskeySectionView: View { addButton } } header: { - Text("Passkey") + Text("passkey_title") .foregroundStyle(theme.secondaryColor) } .onAppear { diff --git a/BookPlayer/Profile/CompleteAccount/CompleteAccountView.swift b/BookPlayer/Profile/CompleteAccount/CompleteAccountView.swift index 784b09f86..81e63c154 100644 --- a/BookPlayer/Profile/CompleteAccount/CompleteAccountView.swift +++ b/BookPlayer/Profile/CompleteAccount/CompleteAccountView.swift @@ -23,7 +23,8 @@ struct CompleteAccountView: View { @Environment(\.accountService) private var accountService @EnvironmentObject private var theme: ThemeViewModel - + + var subType: AccessLevel = .pro var onDismiss: () -> Void var body: some View { @@ -83,7 +84,7 @@ struct CompleteAccountView: View { showConfetti: showConfetti ) .errorAlert(error: $loadingState.error) - .navigationTitle("BookPlayer Pro") + .navigationTitle(subType == .lite ? "BookPlayer Lite" : "BookPlayer Pro") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -105,7 +106,12 @@ struct CompleteAccountView: View { .foregroundStyle(theme.linkColor) } } - .alert("pro_welcome_title", isPresented: $showSuccessAlert) { + // The ternary yields String, so the alert takes the non-localizing StringProtocol + // overload — both branches must localize explicitly or the raw key renders verbatim + .alert( + subType == .lite ? "lite_welcome_title".localized : "pro_welcome_title".localized, + isPresented: $showSuccessAlert + ) { Button("ok_button") { onDismiss() } @@ -138,7 +144,7 @@ struct CompleteAccountView: View { } func loadProducts() async throws { - let options = try await accountService.getSubscriptionOptions() + let options = subType == .lite ? try await accountService.getLiteSubscriptionOptions() : try await accountService.getSubscriptionOptions() pricingOptions = options selectedPricingOption = options.first isLoadingPricingOptions = false diff --git a/BookPlayer/Profile/Login/AppleSignInLink.swift b/BookPlayer/Profile/Login/AppleSignInLink.swift index ced879092..14f23adc8 100644 --- a/BookPlayer/Profile/Login/AppleSignInLink.swift +++ b/BookPlayer/Profile/Login/AppleSignInLink.swift @@ -58,6 +58,9 @@ struct AppleSignInLink: View { } .frame(height: 46) .signInWithAppleButtonStyle(theme.useDarkVariant ? .white : .black) + /// Apple's default corner radius is its own, not a house choice — clipping it to the same + /// capsule the rest of our buttons use keeps one shape language on these screens. + .clipShape(Capsule()) .padding(.horizontal, Spacing.M) } } diff --git a/BookPlayer/Profile/Login/LoginDisclaimerSectionView.swift b/BookPlayer/Profile/Login/LoginDisclaimerSectionView.swift index 0a738d8ce..e3b420e9a 100644 --- a/BookPlayer/Profile/Login/LoginDisclaimerSectionView.swift +++ b/BookPlayer/Profile/Login/LoginDisclaimerSectionView.swift @@ -12,17 +12,29 @@ import SwiftUI struct LoginDisclaimerSectionView: View { @EnvironmentObject private var theme: ThemeViewModel + /// The bullets under the shared heading. The default is the cloud pitch, where we do host + /// the files; the media-server screen hosts nothing of theirs and says something different. + let descriptions: [LocalizedStringKey] + + init( + descriptions: [LocalizedStringKey] = [ + "benefits_disclaimer_account_description", + "benefits_disclaimer_subscription_description", + ] + ) { + self.descriptions = descriptions + } + var body: some View { Section { VStack(alignment: .leading, spacing: 10) { Text("benefits_disclaimer_title") .bpFont(.title) - Text("benefits_disclaimer_account_description") - .bpFont(.body) - .foregroundStyle(theme.secondaryColor) - Text("benefits_disclaimer_subscription_description") - .bpFont(.body) - .foregroundStyle(theme.secondaryColor) + ForEach(descriptions.indices, id: \.self) { index in + Text(descriptions[index]) + .bpFont(.body) + .foregroundStyle(theme.secondaryColor) + } } } .listRowBackground(Color.clear) diff --git a/BookPlayer/Profile/Login/LoginSignInButton.swift b/BookPlayer/Profile/Login/LoginSignInButton.swift index f08e02661..766cb946d 100644 --- a/BookPlayer/Profile/Login/LoginSignInButton.swift +++ b/BookPlayer/Profile/Login/LoginSignInButton.swift @@ -54,6 +54,9 @@ struct LoginSignInButton: View { } .frame(height: 46) .signInWithAppleButtonStyle(theme.useDarkVariant ? .white : .black) + /// Apple's default corner radius is its own, not a house choice — clipping it to the same + /// capsule the rest of our buttons use keeps one shape language on these screens. + .clipShape(Capsule()) .padding(.vertical) .padding(.horizontal, Spacing.M) } diff --git a/BookPlayer/Profile/Login/LoginView.swift b/BookPlayer/Profile/Login/LoginView.swift index 955dddcf4..b0ec45be8 100644 --- a/BookPlayer/Profile/Login/LoginView.swift +++ b/BookPlayer/Profile/Login/LoginView.swift @@ -14,47 +14,50 @@ struct LoginView: View { @State private var loadingState = LoadingOverlayState() @State private var showCompleteAccount = false @State private var showPasskeyRegistration = false - + @EnvironmentObject private var theme: ThemeViewModel @Environment(\.dismiss) private var dismiss var body: some View { - ZStack(alignment: .bottom) { - Form { - LoginBenefitSectionView( - imageName: "icloud.and.arrow.up.fill", - title: "benefits_cloudsync_title", - subtitle: "benefits_cloudsync_description" - ) - LoginBenefitSectionView( - imageName: "applewatch.radiowaves.left.and.right", - title: "Apple Watch (Beta)", - subtitle: "benefits_watchapp_description" - ) - LoginBenefitSectionView( - imageName: "paintpalette.fill", - title: "benefits_themesicons_title", - subtitle: "benefits_themesicons_description" - ) - LoginDisclaimerSectionView() - } - .applyListStyle(with: theme, background: theme.systemGroupedBackgroundColor) - .safeAreaInset(edge: .bottom) { - Color.clear - .frame(height: 88) - } - + Form { + LoginBenefitSectionView( + imageName: "icloud.and.arrow.up.fill", + title: "benefits_cloudsync_title", + subtitle: "benefits_cloudsync_description" + ) + LoginBenefitSectionView( + imageName: "applewatch.radiowaves.left.and.right", + title: "Apple Watch (Beta)", + subtitle: "benefits_watchapp_description" + ) + LoginBenefitSectionView( + imageName: "paintpalette.fill", + title: "benefits_themesicons_title", + subtitle: "benefits_themesicons_description" + ) + LoginDisclaimerSectionView() + } + .applyListStyle(with: theme, background: theme.systemGroupedBackgroundColor) + /// The buttons ARE the inset, so the list reserves exactly their height. This used to + /// reserve a hardcoded 88pt and overlay the stack as a ZStack sibling — but the stack is + /// nearer 100pt before Dynamic Type touches it, so the last disclaimer line could not be + /// scrolled clear of the buttons on a short screen. The opaque background matters as much: + /// `AppleSignInLink` is an opaque pill and hides what passes behind it, while + /// `ContinueWithPasskeyButton` is bare text, so the disclaimer used to scroll straight + /// through it. + .safeAreaInset(edge: .bottom) { VStack(spacing: Spacing.S) { AppleSignInLink { hasSubscription in handleSignInResult(hasSubscription: hasSubscription) } - // Continue with Passkey - goes to registration/sign-in screen + /// Goes to the registration/sign-in screen ContinueWithPasskeyButton { showPasskeyRegistration = true } .padding(.bottom, Spacing.S) } + .background(theme.systemGroupedBackgroundColor) } .environment(\.loadingState, loadingState) .listSectionSpacing(Spacing.S2) diff --git a/BookPlayer/Profile/Login/MediaServersLoginView.swift b/BookPlayer/Profile/Login/MediaServersLoginView.swift new file mode 100644 index 000000000..d73070504 --- /dev/null +++ b/BookPlayer/Profile/Login/MediaServersLoginView.swift @@ -0,0 +1,125 @@ +// +// MediaServersLoginView.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 9/4/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import AuthenticationServices +import BookPlayerKit +import SwiftUI + +/// The media-server half of the sign-up funnel, presented when someone without the entitlement +/// asks to stream. `LoginView` is its sibling: same benefit rows and the same sign-in buttons, +/// but it pitches cloud sync and ends on `.pro`, where this pitches the server features and +/// ends on `.lite`. +struct MediaServersLoginView: View { + @EnvironmentObject private var theme: ThemeViewModel + @Environment(\.dismiss) private var dismiss + @Environment(\.accountService) private var accountService + + @State private var loadingState = LoadingOverlayState() + @State private var showCompleteAccount = false + @State private var showPasskeyRegistration = false + + var body: some View { + Form { + /// No "bring your own server" row: this screen is only reachable from inside a connected + /// browser, and connecting a server is free anyway — pitching it on a paywall reads as + /// charging for something the user already has. + LoginBenefitSectionView( + imageName: "waveform", + title: "external_sync_benefit_stream_title", + subtitle: "external_sync_benefit_stream_subtitle" + ) + + LoginBenefitSectionView( + imageName: "arrow.clockwise.icloud", + title: "external_sync_benefit_sync_title", + subtitle: "external_sync_benefit_sync_subtitle" + ) + + /// Not the shared bullets: those explain hosting costs for files we keep, and we keep + /// none of this user's. Theirs answers the question a self-hoster actually asks — + /// why pay when the server is already mine. + LoginDisclaimerSectionView(descriptions: ["external_sync_disclaimer_description"]) + } + .applyListStyle(with: theme, background: theme.systemGroupedBackgroundColor) + /// The buttons ARE the inset, so the list reserves exactly their height. Opaque, because + /// `ContinueWithPasskeyButton` has no background of its own and the disclaimer scrolled + /// straight through it on a short screen. + .safeAreaInset(edge: .bottom) { + VStack(spacing: Spacing.S) { + if accountService.account.id.isEmpty { + AppleSignInLink { hasSubscription in + handleSignInResult(hasSubscription: hasSubscription) + } + + /// Goes to the registration/sign-in screen + ContinueWithPasskeyButton { + showPasskeyRegistration = true + } + .padding(.bottom, Spacing.S) + } else { + PrimaryButton(text: "continue_title".localized) { + showCompleteAccount = true + } + .padding(.horizontal) + .padding(.bottom, Spacing.S) + } + } + .background(theme.systemGroupedBackgroundColor) + } + /// Load-bearing: `AppleSignInLink` reads `\.loadingState` from the environment and writes + /// its progress and its errors there. Without the injection it would write into the + /// `@Entry` placeholder, and a failed sign-in would be swallowed with nothing on screen. + .environment(\.loadingState, loadingState) + .listSectionSpacing(Spacing.S2) + .navigationTitle("external_sync_info_title") + .navigationBarTitleDisplayMode(.inline) + .errorAlert(error: $loadingState.error) + .loadingOverlay(loadingState.show) + /// Presented as a sheet, so there is no back button to fall back on + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .foregroundStyle(theme.linkColor) + } + .accessibilityLabel("close_title") + } + } + .sheet(isPresented: $showCompleteAccount) { + NavigationStack { + CompleteAccountView(subType: .lite) { + dismiss() + } + } + .presentationDetents([.medium]) + } + .sheet(isPresented: $showPasskeyRegistration) { + PasskeyRegistrationView { hasSubscription in + showPasskeyRegistration = false + handleSignInResult(hasSubscription: hasSubscription) + } + } + } + + private func handleSignInResult(hasSubscription: Bool) { + if hasSubscription { + dismiss() + } else { + showCompleteAccount = true + } + } +} + +#Preview { + NavigationStack { + MediaServersLoginView() + } + .environmentObject(ThemeViewModel()) +} diff --git a/BookPlayer/Profile/Passkey/Service/PasskeyService.swift b/BookPlayer/Profile/Passkey/Service/PasskeyService.swift index 9ef3c9818..7180822d1 100644 --- a/BookPlayer/Profile/Passkey/Service/PasskeyService.swift +++ b/BookPlayer/Profile/Passkey/Service/PasskeyService.swift @@ -62,7 +62,7 @@ public final class PasskeyService: NSObject, PasskeyServiceProtocol, @unchecked return response } catch let error as BookPlayerError { - if case .networkErrorWithCode(_, let code) = error, + if case .networkErrorWithCode(_, let code, _) = error, code == "EMAIL_ALREADY_REGISTERED" { throw PasskeyError.emailAlreadyRegistered } diff --git a/BookPlayer/Profile/Profile/ProfileScreen.swift b/BookPlayer/Profile/Profile/ProfileScreen.swift index 49646c9ad..08287edca 100644 --- a/BookPlayer/Profile/Profile/ProfileScreen.swift +++ b/BookPlayer/Profile/Profile/ProfileScreen.swift @@ -8,6 +8,8 @@ import Foundation -enum ProfileScreen: String, Hashable { - case account, tasks +enum ProfileScreen: Hashable { + case account + /// Every queued task, one collapsible section per lane + case queueTasks } diff --git a/BookPlayer/Profile/Profile/ProfileSyncTasksSectionView.swift b/BookPlayer/Profile/Profile/ProfileSyncTasksSectionView.swift index 9d363608c..24379b45c 100644 --- a/BookPlayer/Profile/Profile/ProfileSyncTasksSectionView.swift +++ b/BookPlayer/Profile/Profile/ProfileSyncTasksSectionView.swift @@ -12,30 +12,88 @@ import SwiftUI struct ProfileSyncTasksSectionView: View { @State private var statusMessage: String = "" - @State private var jobsCount = 0 + /// Every lane, summed by the engine that owns them all + @State private var queuedCount = 0 + /// Parked tasks across every lane: the title turns into a warning while any need the user + @State private var pausedCount = 0 private var buttonText: String { - String(format: "queued_sync_tasks_title".localized, jobsCount) + String(format: "queued_sync_tasks_title".localized, queuedCount) } - @Environment(\.syncService) private var syncService + @Environment(\.syncQueueService) private var syncQueueService + @Environment(\.uploadContinuation) private var uploadContinuation + @Environment(\.scenePhase) private var scenePhase + /// Read so the button follows the setting (the offer checks it, but can't observe it) + @AppStorage(Constants.UserDefaults.allowCellularData) private var allowsCellularData = false @EnvironmentObject private var theme: ThemeViewModel + /// The offer checks the cellular setting through a closure SwiftUI can't observe: reading + /// the setting here makes the button follow it + private var showsContinueButton: Bool { + _ = allowsCellularData + return uploadContinuation.isOfferAvailable + } + + /// While the continued task runs, its progress replaces the sync status + private var caption: String { + if uploadContinuation.state == .running, let percent = uploadContinuation.runningPercent { + return String(format: "uploads_running_background_caption".localized, percent) + } + return statusMessage + } + var body: some View { - NavigationLink(value: ProfileScreen.tasks) { + VStack(spacing: Spacing.S2) { + queuedTasksLink + if showsContinueButton { + Button { + uploadContinuation.continueInBackground() + } label: { + Text("continue_uploads_background_button") + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .bpFont(.subheadline) + .foregroundStyle(theme.linkColor) + } + } + .task { await uploadContinuation.refreshState() } + // iOS may drop the request while the app is away + .onChange(of: scenePhase) { _, phase in + guard phase == .active else { return } + Task { await uploadContinuation.refreshState() } + } + } + + private var queuedTasksLink: some View { + NavigationLink(value: ProfileScreen.queueTasks) { VStack { - Text(buttonText) - .bpFont(.body) - .foregroundStyle(theme.linkColor) - Text(statusMessage) + HStack(spacing: Spacing.S4) { + if pausedCount > 0 { + Image(systemName: "exclamationmark.triangle.fill") + .accessibilityHidden(true) + } + Text(buttonText) + } + .bpFont(.body) + .foregroundStyle(pausedCount > 0 ? .red : theme.linkColor) + Text(caption) .bpFont(.caption) .foregroundStyle(theme.secondaryColor) } + // Not by colour alone: VoiceOver hears it too + .accessibilityElement(children: .combine) + .accessibilityValue(pausedCount > 0 ? "sync_tasks_need_attention_voiceover".localized : "") } - .onReceive(syncService.observeTasksCount()) { count in - guard jobsCount != count else { return } - - jobsCount = count + .onReceive(syncQueueService.observeQueueCounts()) { counts in + if queuedCount != counts.total { + queuedCount = counts.total + } + if pausedCount != counts.totalPaused { + pausedCount = counts.totalPaused + } } .onReceive( NotificationCenter.default.publisher(for: .uploadProgressUpdated) @@ -90,21 +148,30 @@ struct ProfileSyncTasksSectionView: View { } } +// Environment defaults are un-setup() placeholders whose count methods trap (see +// CLAUDE.md's DI section) — previews must construct + setup() + inject. #Preview { - @Previewable var syncService: SyncService = { + @Previewable var syncQueueService: SyncQueueService = { let dataManager = DataManager(coreDataStack: CoreDataStack(testPath: "")) let audioMetadataService = AudioMetadataService() let libraryService = LibraryService() libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) let accountService = AccountService() accountService.setup(dataManager: dataManager) - let syncService = SyncService() - syncService.setup(isActive: true, libraryService: libraryService, accountService: accountService, dataManager: dataManager) - - return syncService + let syncQueueService = SyncQueueService() + syncQueueService.setup( + libraryService: libraryService, + getAccessLevel: { accountService.getAccessLevel() }, + verifySyncEntitlement: { nil }, + tasksDataManager: TasksDataManager(), + networkClient: NetworkClient(), + dataManager: dataManager + ) + + return syncQueueService }() ProfileSyncTasksSectionView() .environmentObject(ThemeViewModel()) - .environment(\.syncService, syncService) + .environment(\.syncQueueService, syncQueueService) } diff --git a/BookPlayer/Profile/Profile/ProfileView.swift b/BookPlayer/Profile/Profile/ProfileView.swift index 9a29e2af9..225bd9485 100644 --- a/BookPlayer/Profile/Profile/ProfileView.swift +++ b/BookPlayer/Profile/Profile/ProfileView.swift @@ -15,7 +15,7 @@ struct ProfileView: View { @Environment(\.accountService) private var accountService @Environment(\.playerState) private var playerState @EnvironmentObject private var theme: ThemeViewModel - + var body: some View { NavigationStack(path: $path) { VStack(spacing: 0) { @@ -26,7 +26,7 @@ struct ProfileView: View { } Spacer() - + if accountService.account.hasSubscription, !accountService.account.id.isEmpty { @@ -43,8 +43,8 @@ struct ProfileView: View { switch destination { case .account: AccountView() - case .tasks: - QueuedSyncTasksView() + case .queueTasks: + QueuedTasksView() .miniPlayerSafeAreaInset() } } diff --git a/BookPlayer/Profile/Profile/QueuedSyncTaskRowView.swift b/BookPlayer/Profile/Profile/QueuedSyncTaskRowView.swift index 66380c7c1..d384ed864 100644 --- a/BookPlayer/Profile/Profile/QueuedSyncTaskRowView.swift +++ b/BookPlayer/Profile/Profile/QueuedSyncTaskRowView.swift @@ -17,25 +17,40 @@ struct QueuedSyncTaskRowView: View { let progressKey: String var initialProgress: Double var isUpload: Bool + /// Set while the task is parked: the row shows why, with Retry and Report — or, for a + /// book the app won't upload (too large), its own message and Dismiss + var pause: TaskPause? = nil + var onRetry: () -> Void = {} + var onReport: () -> Void = {} + var onDismiss: () -> Void = {} @EnvironmentObject var themeViewModel: ThemeViewModel var body: some View { - HStack { - Image(systemName: imageName) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 20, height: 20) - .foregroundStyle(themeViewModel.secondaryColor) - .padding([.trailing], 5) - Text(title) - .bpFont(.body) - .foregroundStyle(themeViewModel.primaryColor) - .frame(maxWidth: .infinity, alignment: .leading) - CircularProgressView( - progress: progress, - isHighlighted: true - ) + VStack(alignment: .leading, spacing: Spacing.S2) { + HStack { + Image(systemName: pause == nil ? imageName : "exclamationmark.triangle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .foregroundStyle(pause == nil ? themeViewModel.secondaryColor : .red) + .padding([.trailing], 5) + .accessibilityHidden(true) + Text(title) + .bpFont(.body) + .foregroundStyle(themeViewModel.primaryColor) + .frame(maxWidth: .infinity, alignment: .leading) + if pause == nil { + CircularProgressView( + progress: progress, + isHighlighted: true + ) + } + } + + if let pause { + pausedDetails(pause) + } } .padding([.vertical], 3) .onAppear { @@ -57,6 +72,48 @@ struct QueuedSyncTaskRowView: View { } } +extension QueuedSyncTaskRowView { + @ViewBuilder + private func pausedDetails(_ pause: TaskPause) -> some View { + // The app's own refusal is worded by the app (in the current language); a server + // pause shows the server's message + let isTooLarge = pause.errorCode == UploadFileError.fileTooLarge.code + let message = isTooLarge ? UploadFileError.fileTooLarge.message : pause.message + + Text(message) + .bpFont(.caption) + .foregroundStyle(.red) + .accessibilityLabel(String(format: "sync_task_paused_voiceover".localized, message)) + + HStack(spacing: Spacing.S) { + if isTooLarge { + // Retrying can't make the file smaller, and there's nothing to report + pausedAction("sync_task_dismiss_button", action: onDismiss) + } else { + pausedAction("sync_task_retry_button", action: onRetry) + pausedAction("sync_task_report_button", action: onReport) + } + } + .bpFont(.subheadline) + .foregroundStyle(themeViewModel.linkColor) + } +} + +extension QueuedSyncTaskRowView { + /// Borderless: plain buttons inside a List row would make the whole row one tap target. + /// The label is framed to the 44×44 pt minimum, and the hint names the item for VoiceOver + /// when several rows are parked. + private func pausedAction(_ titleKey: LocalizedStringKey, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(titleKey) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .accessibilityHint(title) + } +} + struct QueuedSyncTaskRowView_Previews: PreviewProvider { static var previews: some View { QueuedSyncTaskRowView( diff --git a/BookPlayer/Profile/Profile/QueuedSyncTasksView.swift b/BookPlayer/Profile/Profile/QueuedSyncTasksView.swift deleted file mode 100644 index 7c585cb08..000000000 --- a/BookPlayer/Profile/Profile/QueuedSyncTasksView.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// QueuedSyncTasksView.swift -// BookPlayer -// -// Created by gianni.carlo on 26/5/23. -// Copyright © 2023 BookPlayer LLC. All rights reserved. -// - -import BookPlayerKit -import SwiftData -import SwiftUI - -struct QueuedSyncTasksView: View { - @AppStorage(Constants.UserDefaults.allowCellularData) - private var allowsCellularData: Bool = false - @State private var queuedJobs = [SyncTaskReference]() - @State private var jobsCount = 0 - @State private var showInfoAlert = false - @State private var networkMonitor = NetworkMonitor() - - @Environment(\.syncService) private var syncService - @EnvironmentObject private var theme: ThemeViewModel - - var body: some View { - List { - ThemedSection { - ForEach(queuedJobs) { job in - QueuedSyncTaskRowView( - imageName: .constant(parseImageName(job.jobType)), - title: job.jobType == .matchUuid ? .constant("sync_library_title".localized) : .constant(job.relativePath), - progressKey: job.progressKey, - initialProgress: job.jobType == .upload ? job.progress : 0, - isUpload: job.jobType == .upload - ) - } - } header: { - if !allowsCellularData && !networkMonitor.isConnectedViaWiFi { - HStack { - Spacer() - Image(systemName: "wifi") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 20, height: 20) - .foregroundStyle(theme.linkColor) - .padding([.trailing], 5) - Text("upload_wifi_required_title".localized) - .bpFont(.body) - .foregroundStyle(theme.secondaryColor) - Spacer() - } - } - } - } - .scrollContentBackground(.hidden) - .background(theme.systemBackgroundColor) - .toolbarColorScheme(theme.useDarkVariant ? .dark : .light, for: .navigationBar) - .navigationTitle("tasks_title") - .navigationBarTitleDisplayMode(.inline) - .alert("", isPresented: $showInfoAlert) { - Button("ok_button", role: .cancel) {} - .foregroundStyle(theme.linkColor) - } message: { - Text("sync_tasks_alert_description") - } - .onReceive( - syncService.observeTasksCount() - .dropFirst() - ) { count in - guard jobsCount != count else { return } - - jobsCount = count - reloadQueuedJobs() - } - .onAppear { - reloadQueuedJobs() - } - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button { - showInfoAlert = true - } label: { - Image(systemName: "info.circle") - } - .foregroundStyle(theme.linkColor) - } - } - } - - func reloadQueuedJobs() { - Task { @MainActor in - let allJobs = await syncService.getAllQueuedJobs() - jobsCount = allJobs.count - queuedJobs = allJobs - } - } - - func parseImageName(_ jobType: SyncJobType) -> String { - switch jobType { - case .upload: - return "arrow.up.to.line" - case .update: - return "arrow.2.circlepath" - case .move: - return "arrow.forward" - case .renameFolder: - return "square.and.pencil" - case .delete: - return "xmark.bin.fill" - case .shallowDelete: - return "xmark.bin" - case .setBookmark: - return "bookmark" - case .deleteBookmark: - return "bookmark.slash" - case .uploadArtwork: - return "photo" - case .matchUuid: - return "app.connected.to.app.below.fill" - } - } -} diff --git a/BookPlayer/Profile/Profile/Views/QueuedTaskDisplay.swift b/BookPlayer/Profile/Profile/Views/QueuedTaskDisplay.swift new file mode 100644 index 000000000..d4e613ab5 --- /dev/null +++ b/BookPlayer/Profile/Profile/Views/QueuedTaskDisplay.swift @@ -0,0 +1,125 @@ +// +// QueuedTaskDisplay.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import Foundation + +/// Display metadata for a task queue key +enum QueueDisplay { + static func name(for queueKey: String) -> String { + switch queueKey { + case TaskQueueKey.sync: + return "queue_library_sync_title".localized + case TaskQueueKey.uploadFile: + return "queue_file_uploads_title".localized + default: + return queueKey.capitalized + } + } + + static func imageName(for queueKey: String) -> String { + switch queueKey { + case TaskQueueKey.sync: + return "arrow.triangle.2.circlepath.icloud" + case TaskQueueKey.uploadFile: + return "square.and.arrow.up.badge.clock" + default: + return "antenna.radiowaves.left.and.right" + } + } +} + +/// One collapsible lane on the Queued Tasks screen +struct QueuedTaskSection: Identifiable { + let queueKey: String + let tasks: [QueuedSyncTask] + + var id: String { queueKey } +} + +extension Array where Element == QueuedSyncTask { + /// Lanes in display order — the sync lane first, then alphabetically — each keeping the + /// engine's order (active tasks first) for its own rows. A drained lane has no section. + func groupedByLane() -> [QueuedTaskSection] { + var laneOrder = [String]() + var tasksByLane = [String: [QueuedSyncTask]]() + for task in self { + if tasksByLane[task.queueKey] == nil { + laneOrder.append(task.queueKey) + } + tasksByLane[task.queueKey, default: []].append(task) + } + + return laneOrder + .sorted { lhs, rhs in + if lhs == TaskQueueKey.sync { return true } + if rhs == TaskQueueKey.sync { return false } + return lhs < rhs + } + .map { QueuedTaskSection(queueKey: $0, tasks: tasksByLane[$0] ?? []) } + } +} + +extension QueuedSyncTask { + /// Jobs name the item they touch (the library match names the whole library); a provider + /// push describes the work instead, since the row's lane already names the provider. + var displayTitle: String { + switch jobType { + case .matchUuid: + return "sync_library_title".localized + case .uploadFile: + // The reference carries the library key (see `handleUploadResult`), so the upload lane + // names the file like every other row — fall back only if a legacy task stored none. + return relativePath.isEmpty ? "task_uploading_file_label".localized : relativePath + case .externalUpdate: + return String(format: "task_updating_progress_label".localized, QueueDisplay.name(for: queueKey)) + default: + return relativePath + } + } + + var displayImageName: String { + switch jobType { + case .upload: + return "arrow.up.to.line" + case .update: + return "arrow.2.circlepath" + case .move: + return "arrow.forward" + case .renameFolder: + return "square.and.pencil" + case .delete: + return "xmark.bin.fill" + case .shallowDelete: + return "xmark.bin" + case .setBookmark: + return "bookmark" + case .deleteBookmark: + return "bookmark.slash" + case .uploadArtwork: + return "photo" + case .matchUuid: + return "app.connected.to.app.below.fill" + case .externalResource: + return "arrow.up.forward.square" + case .externalResourceToDownload: + return "link.badge.plus" + case .deleteExternalResource: + return "xmark.bin" + case .externalUpdate: + return "arrow.2.circlepath" + case .uploadFile: + return "square.and.arrow.up.badge.clock" + } + } + + /// Byte progress arrives through `.uploadProgressUpdated` for the file moving to S3; + /// every other job is an API round-trip with no meaningful percentage. + var tracksByteProgress: Bool { + jobType == .upload || jobType == .uploadFile + } +} diff --git a/BookPlayer/Profile/Profile/Views/QueuedTasksView.swift b/BookPlayer/Profile/Profile/Views/QueuedTasksView.swift new file mode 100644 index 000000000..ffde5f54c --- /dev/null +++ b/BookPlayer/Profile/Profile/Views/QueuedTasksView.swift @@ -0,0 +1,367 @@ +// +// QueuedTasksView.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 31/3/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import MessageUI +import SwiftUI + +/// Every queued task on one screen: a collapsible section per lane (sync first, then the +/// rest alphabetically), fed by the engine that owns all of them. Pushed from Profile and +/// presented as a sheet from the library list when a refresh is blocked by queued jobs. +struct QueuedTasksView: View, BPLogger { + @AppStorage(Constants.UserDefaults.allowCellularData) + private var allowsCellularData: Bool = false + @State private var tasks = [QueuedSyncTask]() + @State private var counts = QueueCounts() + /// Stored inverted on purpose: a lane that appears while the screen is open starts expanded + @State private var collapsedLanes = Set() + @State private var showInfoAlert = false + @State private var networkMonitor = NetworkMonitor() + /// Report's destination: Mail when it's set up, the share sheet otherwise + @State private var reportMail: SyncPauseReportMail? + @State private var reportShare: SyncPauseReportShare? + /// A second tap while the report builds (or its sheet is up) is ignored + @State private var isBuildingReport = false + var monitor = SyncQueueProgressMonitor.shared + + @Environment(\.syncQueueService) private var syncQueueService + @Environment(\.libraryService) private var libraryService + @Environment(\.accountService) private var accountService + @Environment(\.uploadContinuation) private var uploadContinuation + @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled + @Environment(\.scenePhase) private var scenePhase + @EnvironmentObject private var theme: ThemeViewModel + + private var sections: [QueuedTaskSection] { tasks.groupedByLane() } + + var body: some View { + List { + if !allowsCellularData && !networkMonitor.isConnectedViaWiFi { + Section { + EmptyView() + } header: { + wifiRequiredBanner + } + } + + if tasks.isEmpty { + ThemedSection { + emptyState + } + } else { + ForEach(sections) { section in + ThemedSection { + DisclosureGroup(isExpanded: isExpanded(section.queueKey)) { + ForEach(section.tasks) { task in + QueuedSyncTaskRowView( + imageName: .constant(task.displayImageName), + title: .constant(task.displayTitle), + progressKey: task.progressKey, + initialProgress: monitor.getTaskProgress(taskID: task.id), + isUpload: task.tracksByteProgress, + pause: task.pause, + onRetry: { + Task { + await syncQueueService.retryPausedTask(id: task.id) + // A user action: the unblocked uploads may now run at full speed + await uploadContinuation.submitIfNeeded() + } + }, + onReport: { report(task) }, + onDismiss: { syncQueueService.dismissPausedTask(id: task.id) } + ) + } + } label: { + laneHeader(section.queueKey) + } + .tint(theme.linkColor) + } + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(theme.systemBackgroundColor) + .toolbarColorScheme(theme.useDarkVariant ? .dark : .light, for: .navigationBar) + .navigationTitle("queued_tasks_title") + .navigationBarTitleDisplayMode(.inline) + .alert("", isPresented: $showInfoAlert) { + Button("ok_button", role: .cancel) {} + } message: { + Text("sync_tasks_alert_description") + } + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button { + showInfoAlert = true + } label: { + Image(systemName: "info.circle") + } + .accessibilityLabel("info_title") + .foregroundStyle(theme.linkColor) + } + } + .sheet(item: $reportMail) { mail in + SettingsMailView( + recipients: [SyncPauseReport.supportEmail], + subject: mail.report.subject, + messageBody: mail.report.body, + isHTML: true, + attachmentData: AttachmentData( + data: Data(mail.report.text.utf8), + mimeType: "text/plain", + fileName: SyncPauseReport.fileName + ) + ) + } + .sheet(item: $reportShare) { share in + ActivityView(activityItems: [share.fileURL]) + .presentationDetents([.medium, .large]) + } + .task { await uploadContinuation.refreshState() } + // iOS may drop the request while the app is away + .onChange(of: scenePhase) { _, phase in + guard phase == .active else { return } + Task { await uploadContinuation.refreshState() } + } + // Replays the current snapshot on subscribe, so this is the initial load as well. + .onReceive(syncQueueService.observeQueueCounts()) { snapshot in + counts = snapshot + reloadTasks() + } + } + + /// The upload lane offers "Continue in background" when it can help; every other lane + /// (and the upload lane otherwise) is the plain header + @ViewBuilder + private func laneHeader(_ queueKey: String) -> some View { + if queueKey == TaskQueueKey.uploadFile, uploadContinuation.isOfferAvailable { + // Each wording measured against the whole row, so the lane title never breaks + // mid-word to make room for a longer button + ViewThatFits(in: .horizontal) { + laneHeaderRow(queueKey, continueLabel: .full) + laneHeaderRow(queueKey, continueLabel: .short) + laneHeaderRow(queueKey, continueLabel: .icon) + } + } else { + laneHeaderRow(queueKey, continueLabel: nil) + } + } + + private enum ContinueLabel { + case full, short, icon + } + + private func laneHeaderRow(_ queueKey: String, continueLabel: ContinueLabel?) -> some View { + let isBlocked = counts.isBlocked(queueKey) + return HStack(spacing: Spacing.S1) { + HStack(spacing: Spacing.S1) { + Image(systemName: isBlocked ? "exclamationmark.triangle.fill" : QueueDisplay.imageName(for: queueKey)) + .frame(width: 24) + .foregroundStyle(isBlocked ? .red : theme.linkColor) + VStack(alignment: .leading, spacing: 0) { + Text(QueueDisplay.name(for: queueKey)) + .bpFont(.headline) + .foregroundStyle(theme.primaryColor) + if isBlocked { + Text("sync_paused_lane_title") + .bpFont(.caption) + .foregroundStyle(.red) + } + if queueKey == TaskQueueKey.uploadFile { + backgroundUploadStatus + } + } + Spacer() + Text("\(counts.count(in: queueKey))") + .bpFont(.subheadline) + .foregroundStyle(theme.secondaryColor) + } + .accessibilityElement(children: .combine) + // The button sits inside the lane's disclosure label, which VoiceOver may read as one + // element: for VoiceOver the action is offered on the header itself + .accessibilityActions { + if continueLabel != nil, voiceOverEnabled { + Button("continue_in_background_button") { + uploadContinuation.continueInBackground() + } + } + } + if let continueLabel { + continueButton(continueLabel) + } + } + .padding(.vertical, Spacing.S3) + } + + /// What the continued task is doing, under the lane title + @ViewBuilder + private var backgroundUploadStatus: some View { + switch uploadContinuation.state { + case .starting: + Text("uploads_starting_title") + .bpFont(.caption) + .foregroundStyle(theme.secondaryColor) + case .running: + Text("uploads_running_background_title") + .bpFont(.caption) + .foregroundStyle(theme.secondaryColor) + case .idle: + EmptyView() + } + } + + private func continueButton(_ label: ContinueLabel) -> some View { + Button { + uploadContinuation.continueInBackground() + } label: { + Group { + switch label { + case .full: + Text("continue_in_background_button") + case .short: + Text("continue_uploads_short_button") + case .icon: + Image(systemName: "icloud.and.arrow.up") + } + } + .lineLimit(1) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + // Borderless: a plain button in the label would toggle the lane instead + .buttonStyle(.borderless) + .bpFont(.subheadline) + .foregroundStyle(theme.linkColor) + // The 44 pt target reaches into the row's padding instead of growing the header + .padding(.vertical, -Spacing.S3) + // Named for Voice Control in every variant (the icon's symbol name isn't usable); + // VoiceOver uses the header's action instead + .accessibilityLabel("continue_in_background_button") + .accessibilityHidden(voiceOverEnabled) + } + + private var wifiRequiredBanner: some View { + HStack { + Spacer() + Image(systemName: "wifi") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .foregroundStyle(theme.linkColor) + .padding([.trailing], 5) + Text("upload_wifi_required_title") + .bpFont(.body) + .foregroundStyle(theme.secondaryColor) + Spacer() + } + } + + private var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "checkmark.icloud") + .font(.largeTitle) + .imageScale(.large) + .foregroundStyle(.secondary) + + Text("sync_tasks_empty_title") + .bpFont(.headline) + + Text("sync_tasks_empty_description") + .bpFont(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.vertical, 40) + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + } + + private func isExpanded(_ queueKey: String) -> Binding { + Binding( + get: { !collapsedLanes.contains(queueKey) }, + set: { expanded in + if expanded { + collapsedLanes.remove(queueKey) + } else { + collapsedLanes.insert(queueKey) + } + } + ) + } + + private func report(_ task: QueuedSyncTask) { + guard !isBuildingReport, reportMail == nil, reportShare == nil else { return } + isBuildingReport = true + Task { @MainActor in + defer { isBuildingReport = false } + let report = await SyncPauseReport.make( + for: task, + syncQueueService: syncQueueService, + libraryService: libraryService, + accessLevel: accountService.accessLevel + ) + if MFMailComposeViewController.canSendMail() { + reportMail = SyncPauseReportMail(report: report) + } else { + do { + reportShare = SyncPauseReportShare(fileURL: try report.writeToTemporaryFile()) + } catch { + Self.logger.error("Couldn't write the sync report: \(error.localizedDescription)") + } + } + } + } + + func reloadTasks() { + Task { @MainActor in + tasks = await syncQueueService.getOrderedQueuedJobs(activeTaskIDs: Set(monitor.activeTasks.keys)) + } + } +} + +private struct SyncPauseReportMail: Identifiable { + let id = UUID() + let report: SyncPauseReport +} + +private struct SyncPauseReportShare: Identifiable { + let id = UUID() + let fileURL: URL +} + +// MARK: - Preview +// Environment defaults are un-setup() placeholders whose count methods trap (see +// CLAUDE.md's DI section) — previews must construct + setup() + inject, same as +// ProfileSyncTasksSectionView's preview. +#Preview { + @Previewable var syncQueueService: SyncQueueService = { + let dataManager = DataManager(coreDataStack: CoreDataStack(testPath: "")) + let audioMetadataService = AudioMetadataService() + let libraryService = LibraryService() + libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) + let accountService = AccountService() + accountService.setup(dataManager: dataManager) + let syncQueueService = SyncQueueService() + syncQueueService.setup( + libraryService: libraryService, + getAccessLevel: { accountService.getAccessLevel() }, + verifySyncEntitlement: { nil }, + tasksDataManager: TasksDataManager(), + networkClient: NetworkClient(), + dataManager: dataManager + ) + return syncQueueService + }() + + NavigationStack { + QueuedTasksView() + } + .environmentObject(ThemeViewModel()) + .environment(\.syncQueueService, syncQueueService) +} diff --git a/BookPlayer/Profile/Profile/Views/SyncPauseReport.swift b/BookPlayer/Profile/Profile/Views/SyncPauseReport.swift new file mode 100644 index 000000000..3c0f1fbc1 --- /dev/null +++ b/BookPlayer/Profile/Profile/Views/SyncPauseReport.swift @@ -0,0 +1,125 @@ +// +// SyncPauseReport.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import DeviceKit +import SwiftUI +import UIKit + +/// What Report sends for a parked task: the task and why it stopped, every queued task +/// with its state, and the local library (paths with uuids), so support can see what the +/// device has and what it's still trying to tell the server. The server side is looked up +/// from its own records, so nothing is fetched here. +struct SyncPauseReport { + static let supportEmail = "support@bookplayer.app" + static let fileName = "bookplayer_sync_report.txt" + + let pausedTask: QueuedSyncTask + let queuedTasks: [QueuedSyncTask] + let library: [(relativePath: String, uuid: String)] + let appVersion: String + let systemVersion: String + let deviceName: String + + var subject: String { + "Sync paused (\(pausedTask.pause?.errorCode ?? "unknown")) - BookPlayer \(appVersion)" + } + + var body: String { + "

Hello,
A sync task in BookPlayer is paused and needs help.


" + } + + var text: String { + var report = "BookPlayer sync report\n" + report += "App: \(appVersion)\n" + report += "Device: \(deviceName) \(systemVersion)\n" + + report += "\n-- Paused task --\n" + report += describe(pausedTask) + if let pause = pausedTask.pause { + report += " Message: \(pause.message)\n" + report += " Paused at: \(pause.pausedAt.ISO8601Format())\n" + report += " Sentry event: \(pause.sentryEventId ?? "none")\n" + } + + report += "\n-- Queued tasks (\(queuedTasks.count)) --\n" + for (index, task) in queuedTasks.enumerated() { + report += "\n[\(index + 1)] " + describe(task) + } + + report += "\n-- Local library (\(library.count)) --\n" + report += LibraryTreeRepresentation.render(entries: library.map { ($0.relativePath, $0.uuid) }) + return report + } + + private func describe(_ task: QueuedSyncTask) -> String { + var line = "\(task.jobType.rawValue) · lane \(task.queueKey)\n" + if let pause = task.pause { + let status = pause.httpStatus.map { " \($0)" } ?? "" + line += " Status: paused (\(pause.scope.rawValue)) · \(pause.errorCode)\(status)\n" + } else { + line += " Status: pending\n" + } + line += " Task ID: \(task.id)\n" + line += " Uuid: \(task.uuid)\n" + line += " Path: \(task.relativePath)\n" + return line + } + + /// The report as a named file, for the mail attachment and the share-sheet fallback + func writeToTemporaryFile() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(Self.fileName) + try Data(text.utf8).write(to: url, options: .atomic) + return url + } + + /// Same version string the support email uses: version-build plus the tier suffix + static func appVersion(accessLevel: AccessLevel) -> String { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0" + let suffix: String + switch accessLevel { + case .free: + suffix = "" + case .plus: + suffix = "p" + case .pro: + suffix = "c" + case .lite: + suffix = "l" + } + return "\(version)-\(build)\(suffix)" + } + + @MainActor + static func make( + for task: QueuedSyncTask, + syncQueueService: SyncQueueServiceProtocol, + libraryService: LibraryService, + accessLevel: AccessLevel + ) async -> SyncPauseReport { + SyncPauseReport( + pausedTask: task, + queuedTasks: await syncQueueService.getOrderedQueuedJobs(activeTaskIDs: []), + library: libraryService.fetchIdentifiersWithUuids(), + appVersion: appVersion(accessLevel: accessLevel), + systemVersion: "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)", + deviceName: "\(Device.current)" + ) + } +} + +/// The system share sheet, for sending a report when Mail isn't set up +struct ActivityView: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/BookPlayer/SecondOnboarding/Support/SupportProfileView.swift b/BookPlayer/SecondOnboarding/Support/SupportProfileView.swift index e1c4adf5c..5b8184a5e 100644 --- a/BookPlayer/SecondOnboarding/Support/SupportProfileView.swift +++ b/BookPlayer/SecondOnboarding/Support/SupportProfileView.swift @@ -43,11 +43,11 @@ struct SupportProfileView: View { .accessibilityHidden(true) Group { - Text("Create your profile") + Text("create_profile_title") .bpFont(.titleStory) .foregroundStyle(themeViewModel.primaryColor) .padding() - Text("To enable cloud sync, sign into your profile. You can always do this later from within the Profile tab") + Text("create_profile_description") .bpFont(.bodyStory) .foregroundStyle(themeViewModel.primaryColor) .multilineTextAlignment(.center) @@ -70,7 +70,7 @@ struct SupportProfileView: View { Button(action: { viewModel.dismiss() }, label: { - Text("Not now") + Text("not_now_button") .underline() .bpFont(.body) .foregroundStyle(themeViewModel.secondaryColor) diff --git a/BookPlayer/Services/BackgroundSessionWakeCoordinator.swift b/BookPlayer/Services/BackgroundSessionWakeCoordinator.swift new file mode 100644 index 000000000..b7a07bafa --- /dev/null +++ b/BookPlayer/Services/BackgroundSessionWakeCoordinator.swift @@ -0,0 +1,171 @@ +// +// BackgroundSessionWakeCoordinator.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import Foundation +import UIKit + +/// Answers iOS when it relaunches or wakes the app for a background session's events +/// (`application(_:handleEventsForBackgroundURLSession:completionHandler:)`). Calling iOS's +/// completion handler lets it suspend the app, so for the upload sessions it waits until the +/// upload has handled the finished parts and handed the session the next ones — calling it +/// earlier froze those requests until the next wake (P2b). Either order of the handler and +/// the session's "finished events" callback works: the spike saw both. +@MainActor +final class BackgroundSessionWakeCoordinator: BPLogger { + struct Dependencies { + /// Recreates the session(s) behind an identifier, so iOS delivers their events + var activateSessions: (String) -> Void + /// The upload queue's `settleUploads()` + var settleUploads: () async -> Void + /// `SyncService.settleDownloads()`: the finished downloads' follow-up work + var settleDownloads: () async -> Void + var beginBackgroundTask: (@escaping () -> Void) -> UIBackgroundTaskIdentifier + var endBackgroundTask: (UIBackgroundTaskIdentifier) -> Void + /// Longest wait before answering iOS anyway + var timeout: Duration + /// A "finished events" older than this belongs to an earlier wake, not the next one + var staleEventsAfter: TimeInterval = 60 + + static var live: Dependencies { + Dependencies( + activateSessions: { identifier in + if BackgroundTransferSessions.uploadIdentifiers.contains(identifier) { + BackgroundTransferSessions.activateUploadSessions() + } + }, + settleUploads: { + guard let queue = try? await AppServices.shared.awaitCoreServices().syncQueueService else { return } + await queue.settleUploads() + }, + settleDownloads: { + guard let sync = try? await AppServices.shared.awaitCoreServices().syncService else { return } + await sync.settleDownloads() + }, + beginBackgroundTask: { expiration in + UIApplication.shared.beginBackgroundTask(withName: "background-session-wake", expirationHandler: expiration) + }, + endBackgroundTask: { UIApplication.shared.endBackgroundTask($0) }, + timeout: .seconds(25) + ) + } + } + + private let dependencies: Dependencies + /// Per session, this wake's handler; the token keeps a timer from an earlier wake from + /// answering a later one + private var handlers = [String: (token: UUID, handler: () -> Void)]() + /// Sessions whose events finished before iOS handed over their handler, and when + private var finishedWithoutHandler = [String: Date]() + private var observer: NSObjectProtocol? + + init(dependencies: Dependencies = .live) { + self.dependencies = dependencies + observer = NotificationCenter.default.addObserver( + forName: .backgroundSessionFinishedEvents, + object: nil, + queue: .main + ) { [weak self] notification in + guard let identifier = notification.object as? String else { return } + MainActor.assumeIsolated { + self?.sessionFinishedEvents(identifier) + } + } + } + + deinit { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } + + func handleEvents(forSession identifier: String, completionHandler: @escaping () -> Void) { + Self.logger.info("Woken for background session \(identifier)") + // Anything else (e.g. the single-file media-server downloads, whose queue lives only in + // memory) has nothing to resume in the background + guard isOwned(identifier) else { + completionHandler() + return + } + + // A new wake for the same session: the earlier one's handler is still owed an answer + if let previous = handlers[identifier] { + Self.logger.info("Background session \(identifier): superseded by a new wake") + previous.handler() + } + let token = UUID() + handlers[identifier] = (token, completionHandler) + dependencies.activateSessions(identifier) + + if let finishedAt = finishedWithoutHandler.removeValue(forKey: identifier), + Date().timeIntervalSince(finishedAt) < dependencies.staleEventsAfter { + answer(identifier, token: token) + } else { + // The session may never report back (e.g. it isn't created in this launch) + scheduleFallback(for: identifier, token: token) + } + } + + private func sessionFinishedEvents(_ identifier: String) { + guard isOwned(identifier) else { return } + guard let token = handlers[identifier]?.token else { + finishedWithoutHandler[identifier] = Date() + return + } + answer(identifier, token: token) + } + + private func isOwned(_ identifier: String) -> Bool { + BackgroundTransferSessions.uploadIdentifiers.contains(identifier) + || identifier == BackgroundTransferSessions.downloadIdentifier + } + + /// The session's work settles first, under a background task so it isn't frozen + /// mid-request or mid-write: uploads queue their next parts; downloads finish their + /// follow-up (chapters, verification, scheduling — Core Data in the App Group container) + private func answer(_ identifier: String, token: UUID) { + let settle = BackgroundTransferSessions.uploadIdentifiers.contains(identifier) + ? dependencies.settleUploads + : dependencies.settleDownloads + + var backgroundTask = UIBackgroundTaskIdentifier.invalid + let finish: (String) -> Void = { [weak self] reason in + self?.callHandler(for: identifier, token: token, reason: reason) + if backgroundTask != .invalid { + self?.dependencies.endBackgroundTask(backgroundTask) + backgroundTask = .invalid + } + } + // iOS calls the expiration handler on the main thread and expects the task ended there + backgroundTask = dependencies.beginBackgroundTask { + MainActor.assumeIsolated { finish("background time expired") } + } + Task { + await settle() + finish("settled") + } + Task { + try? await Task.sleep(for: dependencies.timeout) + finish("timed out") + } + } + + private func scheduleFallback(for identifier: String, token: UUID) { + Task { + try? await Task.sleep(for: dependencies.timeout) + callHandler(for: identifier, token: token, reason: "no events reported") + } + } + + /// Once per wake: the first of settle, timeout or expiration wins + private func callHandler(for identifier: String, token: UUID, reason: String) { + guard let entry = handlers[identifier], entry.token == token else { return } + handlers[identifier] = nil + Self.logger.info("Background session \(identifier) done (\(reason))") + entry.handler() + } +} diff --git a/BookPlayer/Services/CarPlayManager.swift b/BookPlayer/Services/CarPlayManager.swift index 2e6f470b5..f4a6811df 100644 --- a/BookPlayer/Services/CarPlayManager.swift +++ b/BookPlayer/Services/CarPlayManager.swift @@ -40,6 +40,9 @@ class CarPlayManager: NSObject { func connect(_ interfaceController: CPInterfaceController) { self.interfaceController = interfaceController self.interfaceController?.delegate = self + // The arbiter exists from launch, so this can't race CoreServices on a cold start into + // the car — the subscription this replaced lived in init and silently never bound there. + AppServices.shared.promptSurfaceArbiter.carPlayPresenter = self /// Reset connect-scoped state so a re-connect without a paired disconnect doesn't act on a stale flag self.shouldShowPlayerOnConnect = false self.setupNowPlayingTemplate() @@ -106,6 +109,7 @@ class CarPlayManager: NSObject { } func disconnect() { + AppServices.shared.promptSurfaceArbiter.carPlayPresenter = nil self.interfaceController = nil self.recentTemplate = nil self.libraryTemplate = nil @@ -138,7 +142,8 @@ class CarPlayManager: NSObject { playerManager: coreServices.playerManager, syncService: coreServices.syncService, playerLoaderService: coreServices.playerLoaderService, - preferencesService: coreServices.preferencesService + preferencesService: coreServices.preferencesService, + externalProgressService: coreServices.externalProgressService ) self?.listSyncRefreshService = listRefreshService @@ -749,3 +754,65 @@ extension CarPlayManager: PlaybackSyncProgressDelegate { _ = await contentsFetchTask?.result } } + +extension CarPlayManager { + /// Whether a modal template would actually reach the screen: connected, and nothing already + /// presented. Reporting an optimistic `true` here would drop the prompt entirely, which is + /// the failure the surface arbiter exists to prevent. + var canPresentTemplate: Bool { + guard let interfaceController else { return false } + + return interfaceController.presentedTemplate == nil + } +} + +extension CarPlayManager: ResumeOfferPresenting { + /// The car's half of the resume offer. PromptSurfaceArbiter decides that the car — and only + /// the car — asks; this only presents. No shared flag to clear: the phone never raised one + /// for an offer routed here. + /// + /// Returns false when the car cannot present, so the arbiter hands the prompt back to the + /// phone rather than dropping it. CarPlay allows ONE presented template at a time, so an + /// alert already on screen would make this one fail silently — and `presentTemplate` + /// reports that only through a completion handler we cannot answer synchronously. + func presentResumeOffer(at remoteTime: TimeInterval) -> Bool { + guard canPresentTemplate else { return false } + + showAlert( + BPAlertContent( + title: "resume_playback_alert_title".localized, + message: String( + format: "resume_playback_alert_message".localized, + TimeParser.formatTime(remoteTime) + ), + style: .alert, + actionItems: [ + BPActionItem(title: "yes_button".localized) { + AppServices.shared.coreServices?.playerManager.jumpTo(remoteTime) + }, + BPActionItem(title: "ignore_button".localized) {}, + ] + ) + ) + + return true + } +} + +extension CarPlayManager: PlaybackFailurePresenting { + /// One line and an OK. The phone's copy prints the underlying error and offers the Media + /// Servers shortcut; neither belongs on a dashboard, so the car points at the phone instead. + func presentPlaybackFailure(_ failure: PlaybackFailure) -> Bool { + guard canPresentTemplate else { return false } + + showAlert( + BPAlertContent( + title: failure.carPlayMessage, + style: .alert, + actionItems: [BPActionItem.okAction] + ) + ) + + return true + } +} diff --git a/BookPlayer/Services/ListSyncRefreshService.swift b/BookPlayer/Services/ListSyncRefreshService.swift index dbece3d44..8df133185 100644 --- a/BookPlayer/Services/ListSyncRefreshService.swift +++ b/BookPlayer/Services/ListSyncRefreshService.swift @@ -15,112 +15,47 @@ enum BPSyncRefreshError: Error { case disabled } +/// Refreshes one library level from every remote it has: the BookPlayer cloud (contents and +/// last-played book), the user's synced preferences, and the positions the level's media +/// servers report. Every caller — list appear, pull-to-refresh, sync activation, CarPlay — +/// goes through `syncList(at:)`, so no entry point can forget a step. final class ListSyncRefreshService: BPLogger, ObservableObject { let playerManager: PlayerManagerProtocol let syncService: SyncServiceProtocol let playerLoaderService: PlayerLoaderService let preferencesService: PreferencesSyncServiceProtocol + let externalProgressService: ExternalProgressRefreshing init( playerManager: PlayerManagerProtocol, syncService: SyncServiceProtocol, playerLoaderService: PlayerLoaderService, - preferencesService: PreferencesSyncServiceProtocol + preferencesService: PreferencesSyncServiceProtocol, + externalProgressService: ExternalProgressRefreshing ) { self.playerManager = playerManager self.syncService = syncService self.playerLoaderService = playerLoaderService self.preferencesService = preferencesService + self.externalProgressService = externalProgressService } - func syncList(at relativePath: String?, alertPresenter: AlertPresenter) async { + func syncList(at relativePath: String?) async throws { // Pref pull runs in parallel with content sync and is independent of the // item-sync queue state — kick it off first so a queue-blocked content // sync doesn't skip the pref refresh. async let prefPull: Void = { await preferencesService.pullFromServer(force: false) }() + let hasRunFirstSync = syncService.hasRunFirstSync do { if let relativePath { - try await syncService.syncListContents(at: relativePath) - } else if UserDefaults.standard.bool(forKey: Constants.UserDefaults.hasScheduledLibraryContents) == true { - try await syncService.syncListContents(at: nil) - } else { - try await syncService.syncLibraryContents() - } - } catch BPSyncError.reloadLastBook(let relativePath) { - await reloadLastBook(relativePath: relativePath, alertPresenter: alertPresenter) - } catch BPSyncError.differentLastBook(let relativePath) { - await setSyncedLastPlayedItem(relativePath: relativePath, alertPresenter: alertPresenter) - } catch { - Self.logger.trace("Sync contents error: \(error.localizedDescription)") - } - - _ = await prefPull - } - - @MainActor - private func reloadLastBook(relativePath: String, alertPresenter: AlertPresenter) { - let wasPlaying = playerManager.isPlaying - playerManager.stop() - - Task { @MainActor in - do { - try await playerLoaderService.loadPlayer( - relativePath, - autoplay: wasPlaying - ) - } catch BPPlayerError.fileMissing { - alertPresenter.showAlert( - "file_missing_title".localized, - message: - "\("file_missing_description".localized)\n\(relativePath)", - completion: nil - ) - } catch { - alertPresenter.showAlert( - "error_title".localized, - message: error.localizedDescription, - completion: nil - ) - } - } - } - - @MainActor - private func setSyncedLastPlayedItem(relativePath: String, alertPresenter: AlertPresenter) async { - /// Only continue overriding local book if it's not currently playing - guard playerManager.isPlaying == false else { return } - - await syncService.setLibraryLastBook(with: relativePath) - - do { - try await playerLoaderService.loadPlayer( - relativePath, - autoplay: false - ) - } catch BPPlayerError.fileMissing { - alertPresenter.showAlert( - "file_missing_title".localized, - message: - "\("file_missing_description".localized)\n\(relativePath)", - completion: nil - ) - } catch { - alertPresenter.showAlert( - "error_title".localized, - message: error.localizedDescription, - completion: nil - ) - } - } - - func syncList(at relativePath: String?) async throws { - async let prefPull: Void = { await preferencesService.pullFromServer(force: false) }() - - do { - if let relativePath { - try await syncService.syncListContents(at: relativePath) - } else if UserDefaults.standard.bool(forKey: Constants.UserDefaults.hasScheduledLibraryContents) == true { + // Until the first sync has registered this device's items (after signing in, or on + // coming back from a lapse), a folder's listing would delete the ones the server + // hasn't seen yet, such as books imported while sync was off + if hasRunFirstSync { + try await syncService.syncListContents(at: relativePath) + } + } else if hasRunFirstSync { try await syncService.syncListContents(at: nil) } else { try await syncService.syncLibraryContents() @@ -133,7 +68,20 @@ final class ListSyncRefreshService: BPLogger, ObservableObject { Self.logger.trace("Sync contents error: \(error.localizedDescription)") } + // Strictly AFTER the cloud step, never alongside it: the sync wrote this level on the + // background context and the progress ingest writes on the view context, and with no + // merge policy set the two must not overlap. Runs whatever the cloud outcome was — the + // media servers are separate hosts, and the pull is gated on the entitlement inside. + await externalProgressService.refreshItems(at: relativePath) + _ = await prefPull + + // The weekly / became-PRO missing-items pass, off the refresh's path: a pull-to-refresh + // spinner shouldn't wait on it (it returns at once when nothing is due) + if relativePath == nil { + let syncService = syncService + Task { await syncService.scheduleMissingItemsIfNeeded() } + } } @MainActor diff --git a/BookPlayer/Services/PlaybackFailure.swift b/BookPlayer/Services/PlaybackFailure.swift new file mode 100644 index 000000000..07dcccbcc --- /dev/null +++ b/BookPlayer/Services/PlaybackFailure.swift @@ -0,0 +1,53 @@ +// +// PlaybackFailure.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import Foundation + +/// A playback failure on its way to whichever surface the user is on. +/// +/// Carries the REASON rather than finished copy: the phone and the car word it differently and +/// offer different actions, so rendering belongs to the presenter. `PlayerManager` raises this +/// from two async paths — the metadata load and the player item's KVO status — long after the +/// caller that started playback has gone, which is why it cannot present the alert itself. +struct PlaybackFailure: Equatable { + enum Reason: Equatable { + /// No saved connection on this device matches the item's host: the server was never added + /// here, or it was removed. Adding it in Media Servers is the fix. + case missingConnection + /// A stream URL resolved and playback still failed — server unreachable, network gone, or + /// a token that no longer works. + case streamUnavailable + /// Everything else: a local file that won't open, an unrecognized AVFoundation error. + case other + } + + let reason: Reason + /// Title and body exactly as the phone has always shown them, error code and `NSError` dump + /// included — they are worth keeping for support threads. Neither reaches the car, which + /// words its own single line: a domain and a `userInfo` dump is not dashboard copy. + let phoneTitle: String + let phoneMessage: String? + /// Whether the Media Servers shortcut can actually fix this one. False when the tier cannot + /// stream at all, since adding a server would not help. + let canOfferMediaServers: Bool + + /// What the car says. One line, no error codes, and it always points at the phone — the car + /// is never where a media server gets configured. + /// + /// Gated on `canOfferMediaServers` for the same reason the phone hides its shortcut: telling + /// a tier that cannot stream to go connect a server is advice that leads nowhere. The two + /// surfaces have to agree on that, or they hold different answers to one decision. + var carPlayMessage: String { + switch reason { + case .missingConnection where canOfferMediaServers: + return "carplay_missing_connection_message".localized + case .missingConnection, .streamUnavailable, .other: + return "carplay_playback_failed_message".localized + } + } +} diff --git a/BookPlayer/Services/PromptSurfaceArbiter.swift b/BookPlayer/Services/PromptSurfaceArbiter.swift new file mode 100644 index 000000000..b7c093671 --- /dev/null +++ b/BookPlayer/Services/PromptSurfaceArbiter.swift @@ -0,0 +1,109 @@ +// +// PromptSurfaceArbiter.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import Combine +import UIKit + +/// A surface that can ask the user whether to jump to a position another device reached. +/// +/// Returns whether it actually presented: a registered CarPlay manager that cannot put a +/// template on screen has to say so, or the prompt disappears instead of falling back to the +/// phone. Deliberately NOT `@discardableResult` — discarding the answer is the bug. +@MainActor +protocol ResumeOfferPresenting: AnyObject { + func presentResumeOffer(at remoteTime: TimeInterval) -> Bool +} + +/// A surface that can tell the user playback failed. +/// +/// Takes the failure rather than a rendered alert, because the two surfaces answer it +/// differently: the phone offers the Media Servers shortcut and prints the underlying error, +/// the car shows one line and an OK, since neither configuring a server nor reading an +/// `NSError` dump is something to do while driving. +@MainActor +protocol PlaybackFailurePresenting: AnyObject { + func presentPlaybackFailure(_ failure: PlaybackFailure) -> Bool +} + +/// Decides WHICH surface talks to the user — and only one ever does. +/// +/// `ExternalProgressService` publishes remote positions; this is the single subscriber. Two +/// subscribers each applying half a rule left a gap: the car presented when the app was +/// inactive, but the coordinator had already raised the phone's flag, so a driver who ignored +/// the car alert got asked again on unlocking the phone. A prompt now belongs to whichever +/// surface received it, and the other never sees it. +/// +/// Playback failures follow the SAME rule rather than getting their own arbiter, because a +/// second object holding a second copy of "which surface may speak" is exactly the drift that +/// bug came from. +/// +/// Created eagerly next to `PlayerState`, not with `CoreServices`: CarPlay registers itself +/// here from `connect()`, which on a cold launch into the car can run before the services +/// exist. Registration and subscription are decoupled so that ordering cannot matter — the +/// previous subscription lived on `CarPlayManager.init` and silently never bound on exactly +/// that path. +@MainActor +final class PromptSurfaceArbiter { + /// Set by CarPlayManager on connect, cleared on disconnect. + weak var carPlayPresenter: (ResumeOfferPresenting & PlaybackFailurePresenting)? + + private let playerState: PlayerState + private let isAppActive: () -> Bool + private var disposeBag = Set() + + init( + playerState: PlayerState, + isAppActive: @escaping () -> Bool = { UIApplication.shared.applicationState == .active } + ) { + self.playerState = playerState + self.isAppActive = isAppActive + } + + func bind(to service: ExternalProgressService) { + service.promptablePositionPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] position in + self?.route(position) + } + .store(in: &disposeBag) + } + + /// The car only when the phone can't ask: an active app shows the SwiftUI alert, and asking + /// twice for one decision is worse than either surface asking once. + func route(_ position: ExternalPlaybackProgress) { + if presentOnCarPlay({ $0.presentResumeOffer(at: position.currentTime) }) { return } + + // Don't clobber a prompt the user is already looking at. + guard !playerState.showResumePopup else { return } + + playerState.remotePlayTime = position.currentTime + playerState.showResumePopup = true + } + + /// Same surface rule as the resume offer. The car is deliberately NOT given a copy when the + /// app is active: `CPAlertTemplate` is modal on the dashboard until someone taps it, so a + /// failure caused by a passenger tapping around in the app would block the driver's screen. + /// The cost is that a failure raised from the car while the app happens to be foregrounded + /// lands on the phone — accepted, rather than putting modal templates in front of a driver. + func routeFailure(_ failure: PlaybackFailure) { + if presentOnCarPlay({ $0.presentPlaybackFailure(failure) }) { return } + + playerState.pendingFailure = failure + } + + /// Whether the car took it. A presenter that is registered but can no longer present (its + /// interface controller went away between connect and here) reports false, and the caller + /// falls through to the phone rather than dropping the prompt on the floor. + private func presentOnCarPlay( + _ present: (ResumeOfferPresenting & PlaybackFailurePresenting) -> Bool + ) -> Bool { + guard let carPlayPresenter, !isAppActive() else { return false } + + return present(carPlayPresenter) + } +} diff --git a/BookPlayer/Services/SyncPauseReporter.swift b/BookPlayer/Services/SyncPauseReporter.swift new file mode 100644 index 000000000..577cbc810 --- /dev/null +++ b/BookPlayer/Services/SyncPauseReporter.swift @@ -0,0 +1,83 @@ +// +// SyncPauseReporter.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import Foundation +import Sentry + +/// Reports each parked sync task to Sentry once, so a new failure kind surfaces without +/// waiting for a support email. The event carries only what identifies the failure — job +/// type, error code, HTTP status, lane, pause scope and the item's uuid (to look it up in +/// the server's sync log). Never the server's message or the item's path: both embed file +/// names — and no breadcrumbs, since our own API's request breadcrumbs keep their query +/// strings, which carry `relativePath`. The fingerprint groups one issue per job type and +/// error code. +final class SyncPauseReporter: BPLogger { + private let syncQueueService: SyncQueueServiceProtocol + private let capture: (Event) -> SentryId + /// This session's reports: the event id is written back to the task asynchronously, so a + /// quick re-park (Retry that fails the same way) could otherwise arrive before it lands + private var reportedTaskIds = Set() + private var observer: NSObjectProtocol? + + init( + syncQueueService: SyncQueueServiceProtocol, + capture: @escaping (Event) -> SentryId = { event in + SentrySDK.capture(event: event) { scope in scope.clearBreadcrumbs() } + } + ) { + self.syncQueueService = syncQueueService + self.capture = capture + // The queue posts on main + observer = NotificationCenter.default.addObserver( + forName: .syncTaskPaused, + object: nil, + queue: .main + ) { [weak self] notification in + guard let task = notification.object as? QueuedSyncTask else { return } + self?.report(task) + } + } + + deinit { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } + + func report(_ task: QueuedSyncTask) { + guard + let pause = task.pause, + pause.sentryEventId == nil, + reportedTaskIds.insert(task.id).inserted + else { return } + + let event = Event(level: .warning) + event.message = SentryMessage(formatted: "Sync task paused: \(task.jobType.rawValue) \(pause.errorCode)") + event.fingerprint = ["sync-paused", task.jobType.rawValue, pause.errorCode] + var tags = [ + "sync.job_type": task.jobType.rawValue, + "sync.error_code": pause.errorCode, + "sync.lane": task.queueKey, + "sync.pause_scope": pause.scope.rawValue, + ] + if let status = pause.httpStatus { + tags["sync.http_status"] = String(status) + } + event.tags = tags + event.extra = ["item_uuid": task.uuid] + + let eventId = capture(event) + // Crash reports off: nothing was sent, so the task stays unreported (here too) for when + // they're turned back on + guard eventId != SentryId.empty else { + reportedTaskIds.remove(task.id) + return + } + syncQueueService.recordPauseReport(eventId: eventId.sentryIdString, forTask: task.id) + } +} diff --git a/BookPlayer/Services/UploadContinuationController.swift b/BookPlayer/Services/UploadContinuationController.swift new file mode 100644 index 000000000..0839dbd11 --- /dev/null +++ b/BookPlayer/Services/UploadContinuationController.swift @@ -0,0 +1,397 @@ +// +// UploadContinuationController.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BackgroundTasks +import BookPlayerKit +import Combine +import Foundation +import UIKit + +/// What the continued task needs of `BGContinuedProcessingTask` (a seam for tests) +protocol ContinuedUploadTask: AnyObject { + var progress: Progress { get } + var expirationHandler: (() -> Void)? { get set } + func updateTitle(_ title: String, subtitle: String) + func setTaskCompleted(success: Bool) +} + +extension BGContinuedProcessingTask: ContinuedUploadTask {} + +/// Progress across every book of one continued run: bytes for the progress bar, and +/// "(x / n) file name" for the Live Activity's subtitle. `n` is every book seen waiting +/// since the run began (it grows as more are queued); a book no longer waiting counts as +/// done, whether it uploaded or was dropped. +struct UploadContinuationProgress { + private(set) var order = [String]() + private var sizes = [String: Int64]() + private var names = [String: String]() + private(set) var waiting = [String]() + private var waitingSet = Set() + /// The book the upload lane is sending, and how far along it is + private(set) var current: (uuid: String, fraction: Double)? + + mutating func update(waiting pending: [PendingBookUpload]) { + for book in pending { + if sizes[book.uuid] == nil { + order.append(book.uuid) + } + // The largest size seen: a file read as 0 once mustn't shrink the total + sizes[book.uuid] = max(sizes[book.uuid] ?? 0, book.fileSize) + names[book.uuid] = book.fileName + } + waiting = pending.map(\.uuid) + waitingSet = Set(waiting) + if let current, !waitingSet.contains(current.uuid) { + self.current = nil + } + } + + mutating func updateProgress(uuid: String, fraction: Double) { + guard waitingSet.contains(uuid) else { return } + current = (uuid, min(max(fraction, 0), 1)) + } + + var isFinished: Bool { waiting.isEmpty } + + var totalBytes: Int64 { sizes.values.reduce(0, +) } + + var completedBytes: Int64 { + let done = sizes.reduce(Int64(0)) { $0 + (waitingSet.contains($1.key) ? 0 : $1.value) } + guard let current, let size = sizes[current.uuid] else { return done } + return done + Int64(Double(size) * current.fraction) + } + + /// 1-based position of the book being sent (or next up), and how many there are + var position: (index: Int, count: Int) { + let done = order.count - waiting.count + return (min(done + 1, max(order.count, 1)), order.count) + } + + /// The book being sent, else the next one waiting + var currentFileName: String? { + let uuid = current?.uuid ?? waiting.first + return uuid.flatMap { names[$0] } + } +} + +/// Keeps book uploads running at full speed while the app is in the background, through a +/// `BGContinuedProcessingTask` (its Live Activity: "Uploading files", "(x / n) name"). +/// Submitted whenever books are queued for upload (an import, the first sync after signing +/// in or subscribing), on a parked task's Retry, and from the "Continue in background" +/// buttons (Queued Tasks, Profile); covers the whole queue, not one book, +/// and ends when nothing is waiting — or nothing can move (a blocked lane, no S3 access). +/// When it expires (or the user cancels it) the parts keep going in the background +/// sessions, only slower. +@MainActor +@Observable +final class UploadContinuationController: BPLogger { + enum State: Equatable { + case idle + /// Submitted; iOS hasn't launched it yet + case starting + case running + } + + struct Dependencies { + /// Whether this account's uploads reach S3 now (PRO, sync on) + var canUpload: () -> Bool + /// The transfer setting allows uploading on the current network + var networkAllowsUploads: () -> Bool + /// iOS only accepts a continued task from the foreground + var isForeground: () -> Bool + var pendingUploads: () async -> PendingBookUploads + var submit: (BGTaskRequest) async throws -> Void + /// Whether iOS still holds the submitted request (it may drop a queued one) + var hasPendingRequest: () async -> Bool + /// The queue's counts: a change can mean a book finished, was added or got blocked + var queueChanges: () -> AnyPublisher + /// The counts right now (the subscription above is throttled): a Retry that just + /// unblocked a lane must count as runnable at once + var currentCounts: () -> QueueCounts + /// A queue that's empty for this long is done (a book moving between lanes shows up + /// again within it) + var confirmEmptyAfter: Duration = .seconds(1) + } + + static let identifier = "\(Bundle.main.configurationString(for: .bundleIdentifier)).uploads.continued" + + private(set) var state = State.idle + /// Whole percent of the running task, for the "Uploading in background · 37%" caption + private(set) var runningPercent: Int? + /// The latest queue counts (throttled), for what the buttons offer + private(set) var counts = QueueCounts() + + @ObservationIgnored private var dependencies: Dependencies? + @ObservationIgnored private var task: ContinuedUploadTask? + /// A task iOS launched before the services were up (a cold launch into it) + @ObservationIgnored private var heldTask: ContinuedUploadTask? + @ObservationIgnored private var progress = UploadContinuationProgress() + @ObservationIgnored private var subscriptions = Set() + @ObservationIgnored private var queueSubscription: AnyCancellable? + @ObservationIgnored private var lastSubtitle: String? + @ObservationIgnored private var queuedObserver: NSObjectProtocol? + @ObservationIgnored private var isRefreshing = false + /// Our own submit is on its way to iOS (on iOS 27 it's handed over asynchronously): not + /// a request iOS dropped + @ObservationIgnored private var isSubmitting = false + /// Bumped per submit: a "no pending request" answer from before a newer submit is stale + @ObservationIgnored private var submitGeneration = 0 + @ObservationIgnored private var refreshAgain = false + + /// `nonisolated` so an environment default (`@Entry`) can build the placeholder + nonisolated init() {} + + /// Wired once the core services exist + func setup(dependencies: Dependencies) { + self.dependencies = dependencies + // Always current, so a submit can tell a queue that can't move (a blocked lane); a + // running task re-reads on it. Throttled: every store and pop changes the counts. + queueSubscription = dependencies.queueChanges() + .throttle(for: .milliseconds(500), scheduler: DispatchQueue.main, latest: true) + .sink { [weak self] counts in + guard let self else { return } + self.counts = counts + if self.task != nil { + Task { await self.refresh() } + } + } + queuedObserver = NotificationCenter.default.addObserver( + forName: .bookUploadsQueued, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + Task { await self.submitIfNeeded() } + } + } + if let heldTask { + self.heldTask = nil + attach(heldTask) + } + } + + /// Registered at launch: iOS calls it with the task, on a queue of its own + static func register(controller: @escaping @MainActor () -> UploadContinuationController) { + BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in + guard let task = task as? BGContinuedProcessingTask else { + task.setTaskCompleted(success: false) + return + } + Task { @MainActor in controller().attach(task) } + } + } + + func submitIfNeeded() async { + guard let dependencies else { return } + // A request iOS accepted but dropped (or never ran) mustn't block every later one + await refreshState() + guard state == .idle else { return } + guard + dependencies.isForeground(), + dependencies.canUpload(), + dependencies.networkAllowsUploads() + else { return } + + let pending = await dependencies.pendingUploads() + // Nothing waiting, or nothing that can move (e.g. behind a paused lane): a Live + // Activity that fails at once helps no one + guard state == .idle, !pending.books.isEmpty, canMakeProgress(pending) else { return } + + var initial = UploadContinuationProgress() + initial.update(waiting: pending.books) + let request = BGContinuedProcessingTaskRequest( + identifier: Self.identifier, + title: Self.title, + subtitle: Self.subtitle(for: initial) + ) + state = .starting + submitGeneration += 1 + isSubmitting = true + defer { isSubmitting = false } + do { + try await dependencies.submit(request) + Self.logger.info("Continued upload task submitted for \(pending.books.count) book(s)") + } catch { + Self.logger.error("Continued upload task not submitted: \(error.localizedDescription)") + state = .idle + } + } + + func attach(_ task: ContinuedUploadTask) { + // Answered first, whatever else happens: also what cancelling the Live Activity calls. + // Answered here, on iOS's queue, not after a hop to main; the parts carry on in the + // background sessions. + task.expirationHandler = { [weak self, weak task] in + task?.setTaskCompleted(success: false) + Task { @MainActor in + guard let self, let task else { return } + // Expired while held: never attach it later + if self.heldTask === task { + self.heldTask = nil + } + guard self.task === task else { return } + self.finish(success: false, reason: "expired", alreadyCompleted: true) + } + } + guard let dependencies else { + heldTask = task + return + } + // A second launch replaces the first run + if self.task != nil { + finish(success: false, reason: "replaced") + } + self.task = task + state = .running + progress = UploadContinuationProgress() + lastSubtitle = nil + + NotificationCenter.default.publisher(for: .uploadProgressUpdated) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + guard + let self, + let uuid = notification.userInfo?["uuid"] as? String, + let fraction = notification.userInfo?["progress"] as? Double + else { return } + self.progress.updateProgress(uuid: uuid, fraction: fraction) + self.report() + } + .store(in: &subscriptions) + + Task { await refresh() } + } + + /// One at a time, the latest state winning: a queue change during a read re-reads after + /// it, so snapshots never apply out of order and a burst of changes costs two reads + private func refresh() async { + guard !isRefreshing else { + refreshAgain = true + return + } + isRefreshing = true + defer { isRefreshing = false } + repeat { + refreshAgain = false + await refreshOnce() + } while refreshAgain && task != nil + } + + private func refreshOnce() async { + guard let dependencies, let task else { return } + var pending = await dependencies.pendingUploads() + guard self.task === task else { return } + + if pending.books.isEmpty { + // A book moving between lanes (or handed back to the sync lane) can look absent + // for a moment: only a queue that stays empty is done + try? await Task.sleep(for: dependencies.confirmEmptyAfter) + pending = await dependencies.pendingUploads() + guard self.task === task else { return } + guard pending.books.isEmpty else { + progress.update(waiting: pending.books) + report() + return + } + progress.update(waiting: []) + // The bar ends full (also when no size could be read) + task.progress.totalUnitCount = max(progress.totalBytes, 1) + task.progress.completedUnitCount = task.progress.totalUnitCount + let parked = pending.parkedCount > 0 + finish(success: !parked, reason: parked ? "only parked uploads left" : "queue done") + return + } + + progress.update(waiting: pending.books) + guard canMakeProgress(pending) else { + finish(success: false, reason: "nothing can upload now") + return + } + report() + } + + /// Something waiting sits in a lane that can run: no S3 access, or every waiting book + /// behind a blocked lane (a lane-level or account-level pause), means waiting on the user + private func canMakeProgress(_ pending: PendingBookUploads) -> Bool { + guard let dependencies, dependencies.canUpload() else { return false } + let counts = dependencies.currentCounts() + return pending.books.contains { !counts.isBlocked($0.queueKey) } + } + + // MARK: - The "Continue in background" buttons + + /// Whether the buttons show: uploads waiting in a lane that can run, S3 access, a + /// network the setting allows, and no task starting or running + var isOfferAvailable: Bool { + guard let dependencies, state == .idle else { return false } + let uploadLane = TaskQueueKey.uploadFile + let waiting = counts.count(in: uploadLane) - counts.pausedCount(in: uploadLane) + return waiting > 0 + && !counts.isBlocked(uploadLane) + && dependencies.canUpload() + && dependencies.networkAllowsUploads() + } + + /// A button tap (a user action, as iOS requires for a continued task) + func continueInBackground() { + Task { await submitIfNeeded() } + } + + /// A request iOS dropped (or never ran) leaves `.starting`: re-checked when a screen + /// showing it appears + func refreshState() async { + guard let dependencies, state == .starting, !isSubmitting else { return } + let generation = submitGeneration + if await !dependencies.hasPendingRequest(), + state == .starting, !isSubmitting, generation == submitGeneration { + state = .idle + } + } + + private func report() { + guard let task else { return } + task.progress.totalUnitCount = max(progress.totalBytes, 1) + task.progress.completedUnitCount = min(progress.completedBytes, task.progress.totalUnitCount) + // Integer math: a floating-point product shows 29% as 28 + let percent = Int(task.progress.completedUnitCount * 100 / max(task.progress.totalUnitCount, 1)) + if percent != runningPercent { + runningPercent = percent + } + // Nothing to name (the queue emptied): keep the last subtitle + guard progress.currentFileName != nil else { return } + let subtitle = Self.subtitle(for: progress) + if subtitle != lastSubtitle { + lastSubtitle = subtitle + task.updateTitle(Self.title, subtitle: subtitle) + } + } + + private func finish(success: Bool, reason: String, alreadyCompleted: Bool = false) { + guard let task else { return } + Self.logger.info("Continued upload task done (\(reason))") + self.task = nil + subscriptions.removeAll() + state = .idle + runningPercent = nil + if !alreadyCompleted { + task.setTaskCompleted(success: success) + } + } + + static var title: String { "continued_upload_title".localized } + + static func subtitle(for progress: UploadContinuationProgress) -> String { + let position = progress.position + return String( + format: "continued_upload_subtitle".localized, + position.index, + position.count, + progress.currentFileName ?? "" + ) + } +} diff --git a/BookPlayer/Services/VoiceOverService.swift b/BookPlayer/Services/VoiceOverService.swift index a30968e80..019f99a27 100644 --- a/BookPlayer/Services/VoiceOverService.swift +++ b/BookPlayer/Services/VoiceOverService.swift @@ -12,9 +12,14 @@ class VoiceOverService { /// the imported filename (matching what `BookView` renders under the /// library display preference). Defaults to false so non-library /// callers (Watch, etc.) keep their current behavior. + /// - Parameter includeSource: When true, the announcement names the media servers the item + /// is linked to. `BookView` draws those as bare glyphs and the row is a single + /// accessibility element, so this is the only way that provenance reaches VoiceOver. + /// Defaults to false for callers whose rows don't draw the glyphs. public static func getAccessibilityLabel( for item: SimpleLibraryItem, - useOriginalFileName: Bool = false + useOriginalFileName: Bool = false, + includeSource: Bool = false ) -> String { let displayPercent = item.isFinished ? 100.0 : item.percentCompleted let remainingTime = item.duration - item.currentTime @@ -24,9 +29,10 @@ class VoiceOverService { remainingTimeLabel += !parsedDuration.isEmpty ? " \(parsedDuration)" : " 0" } let displayTitle = item.displayTitle(useOriginalFileName: useOriginalFileName) + let sourcePrefix = includeSource ? self.sourcePrefix(for: item) : "" switch item.type { case .book: - return String.localizedStringWithFormat( + return sourcePrefix + String.localizedStringWithFormat( "voiceover_book_progress".localized, displayTitle, item.details, @@ -34,13 +40,13 @@ class VoiceOverService { item.durationFormatted ) + ", \(remainingTimeLabel)" case .folder: - return String.localizedStringWithFormat( + return sourcePrefix + String.localizedStringWithFormat( "voiceover_playlist_progress".localized, displayTitle, displayPercent ) case .bound: - return String.localizedStringWithFormat( + return sourcePrefix + String.localizedStringWithFormat( "voiceover_bound_books_progress".localized, displayTitle, displayPercent, @@ -49,6 +55,22 @@ class VoiceOverService { } } + /// `"Jellyfin, "` — the spoken counterpart of the provider glyphs, and in the same + /// position: they lead the subtitle line, so the names lead the announcement. + /// + /// Deliberately unlocalized. These are brand names, and wrapping them in a `"from %@"` key + /// would be worse than bare: `localized` is a plain `NSLocalizedString` with no fallback to + /// Base, so every locale missing the key would announce the key itself and swallow the name. + /// Empty when the item isn't linked to a media server. + private static func sourcePrefix(for item: SimpleLibraryItem) -> String { + let providers = (item.externalResources?.displayOrderedMediaServerResources ?? []) + .map { $0.providerName.capitalized } + + guard !providers.isEmpty else { return "" } + + return providers.joined(separator: ", ") + ", " + } + // MARK: - PlayerMetaView public static func playerMetaText( diff --git a/BookPlayer/Settings/Icons/SettingsAppIconsView.swift b/BookPlayer/Settings/Icons/SettingsAppIconsView.swift index 257ea8cf8..c3a5a137b 100644 --- a/BookPlayer/Settings/Icons/SettingsAppIconsView.swift +++ b/BookPlayer/Settings/Icons/SettingsAppIconsView.swift @@ -67,20 +67,16 @@ extension Bundle { var icons = [Icon]() - if #available(iOS 26.0, *) { - for var icon in decodedIcons { - if icon.title == "Pride" { - icon.id += "26" - icon.imageName += "-26" - } else if icon.title == "Fruit Based" { - icon.id += "26" - icon.title = "Books" - icon.imageName += "-26" - } - icons.append(icon) + for var icon in decodedIcons { + if icon.title == "Pride" { + icon.id += "26" + icon.imageName += "-26" + } else if icon.title == "Fruit Based" { + icon.id += "26" + icon.title = "Books" + icon.imageName += "-26" } - } else { - icons = decodedIcons + icons.append(icon) } return icons diff --git a/BookPlayer/Settings/Sections/DebugFileTransferable.swift b/BookPlayer/Settings/Sections/DebugFileTransferable.swift index b74ef8455..6653b5d61 100644 --- a/BookPlayer/Settings/Sections/DebugFileTransferable.swift +++ b/BookPlayer/Settings/Sections/DebugFileTransferable.swift @@ -42,129 +42,25 @@ struct DebugFileTransferable: Transferable { let accountService: AccountService let syncService: SyncService + /// Local state only: what the server holds (and did) is read from its own records, which + /// the tree's uuids match exactly func generateDebugData() async -> Data { - var remoteIdentifiers: [String]? - var syncError: String? - let syncJobsInformation = await getSyncOperationsInformation() - if syncService.isActive { - do { - remoteIdentifiers = try await syncService.fetchSyncedIdentifiers() - } catch { - syncError = "Error fetching remote identifiers: \(error.localizedDescription)" - } - } - - let localidentifiers = libraryService.fetchIdentifiers() - - var libraryRepresentation = getLibraryRepresentation( - localidentifiers: localidentifiers, - remoteIdentifiers: remoteIdentifiers + // A view-context read, and the export runs off main + let libraryService = libraryService + let entries = await MainActor.run { libraryService.fetchIdentifiersWithUuids() } + var libraryRepresentation = LibraryTreeRepresentation.render( + entries: entries.map { ($0.relativePath, $0.uuid) } ) - if let remoteIdentifiers, - let remoteOnlyInfo = getRemoteOnlyInformation( - localidentifiers: localidentifiers, - remoteIdentifiers: remoteIdentifiers - ) - { - libraryRepresentation += remoteOnlyInfo - } - libraryRepresentation += getStorageBreakdown() libraryRepresentation += syncJobsInformation - if let syncError { - libraryRepresentation += "\n\n⚠️ Sync Error:\n\(syncError)\n" - } - return libraryRepresentation.data(using: .utf8)! } - /// Get a representation of the library like with the `tree` command - /// Note: For the first status, '✓' means the backing file exists, and '𐄂' that it's missing locally, - /// and for the second status, ☐ means the file is not uploaded yet, and ☑ that it's already synced - func getLibraryRepresentation( - localidentifiers: [String], - remoteIdentifiers: [String]? - ) -> String { - let identifiers = libraryService.fetchIdentifiers() - - var libraryRepresentation = "Library\n.\n" - let processedFolderURL = DataManager.getProcessedFolderURL() - - let baseSeparator = "| " - var nestedLevel = 0 - - for (index, identifier) in identifiers.enumerated() { - let fileURL = processedFolderURL.appendingPathComponent(identifier) - let fileExistsRepresentation = getFileExistsRepresentation( - identifier: identifier, - fileURL: fileURL, - remoteIdentifiers: remoteIdentifiers - ) - let isLast = index == (identifiers.endIndex - 1) - let newNestedLevel = identifier.components(separatedBy: "/").count - 1 - var horizontalSeparator = String(repeating: baseSeparator, count: newNestedLevel) - - if nestedLevel != newNestedLevel || isLast || fileURL.isDirectoryFolder { - horizontalSeparator += horizontalSeparator + "`-- " - } else { - horizontalSeparator += horizontalSeparator + "|-- " - } - - libraryRepresentation += "\(horizontalSeparator)\(fileExistsRepresentation) \(fileURL.lastPathComponent)\n" - - nestedLevel = newNestedLevel - } - - return libraryRepresentation - } - - func getFileExistsRepresentation( - identifier: String, - fileURL: URL, - remoteIdentifiers: [String]? - ) -> String { - let localRepresentation = - FileManager.default.fileExists(atPath: fileURL.path) - ? "[✓]" - : "[𐄂]" - - guard let remoteIdentifiers else { - return localRepresentation - } - - if remoteIdentifiers.contains(identifier) { - return localRepresentation + "[☑]" - } else { - return localRepresentation + "[☐]" - } - } - - func getRemoteOnlyInformation( - localidentifiers: [String], - remoteIdentifiers: [String] - ) -> String? { - var remoteOnlyIdentifiers = Array(Set(remoteIdentifiers).subtracting(Set(localidentifiers))) - - guard !remoteOnlyIdentifiers.isEmpty else { - return nil - } - - remoteOnlyIdentifiers.sort(by: { $0.localizedStandardCompare($1) == ComparisonResult.orderedAscending }) - - var remoteInfo = "\n\nRemote only items:\n" - - for remoteOnlyIdentifier in remoteOnlyIdentifiers { - remoteInfo += "\(remoteOnlyIdentifier)\n" - } - - return remoteInfo - } - func getStorageBreakdown() -> String { var info = "\n\n--- Storage Breakdown ---\n" let fm = FileManager.default @@ -404,3 +300,40 @@ struct DebugFileTransferable: Transferable { return info } } + +/// The library as a `tree`-style listing, shared by the debug file and the sync report +enum LibraryTreeRepresentation { + /// A representation of the library like the `tree` command's: '✓' means the backing file + /// exists on this device, '𐄂' that it's missing + /// - Parameter entries: every item's path, in library order, with its uuid when the + /// listing should show it + static func render(entries: [(relativePath: String, uuid: String?)]) -> String { + var libraryRepresentation = "Library\n.\n" + let processedFolderURL = DataManager.getProcessedFolderURL() + + let baseSeparator = "| " + var nestedLevel = 0 + + for (index, entry) in entries.enumerated() { + let identifier = entry.relativePath + let fileURL = processedFolderURL.appendingPathComponent(identifier) + let fileExistsRepresentation = FileManager.default.fileExists(atPath: fileURL.path) ? "[✓]" : "[𐄂]" + let isLast = index == (entries.endIndex - 1) + let newNestedLevel = identifier.components(separatedBy: "/").count - 1 + var horizontalSeparator = String(repeating: baseSeparator, count: newNestedLevel) + + if nestedLevel != newNestedLevel || isLast || fileURL.isDirectoryFolder { + horizontalSeparator += horizontalSeparator + "`-- " + } else { + horizontalSeparator += horizontalSeparator + "|-- " + } + + let uuidSuffix = entry.uuid.map { " (\($0))" } ?? "" + libraryRepresentation += "\(horizontalSeparator)\(fileExistsRepresentation) \(fileURL.lastPathComponent)\(uuidSuffix)\n" + + nestedLevel = newNestedLevel + } + + return libraryRepresentation + } +} diff --git a/BookPlayer/Settings/Sections/PlayerControls/GlobalSpeedSectionView.swift b/BookPlayer/Settings/Sections/PlayerControls/GlobalSpeedSectionView.swift index c9cecbc59..97e41b92b 100644 --- a/BookPlayer/Settings/Sections/PlayerControls/GlobalSpeedSectionView.swift +++ b/BookPlayer/Settings/Sections/PlayerControls/GlobalSpeedSectionView.swift @@ -32,7 +32,7 @@ struct GlobalSpeedSectionView: View { .foregroundStyle(theme.linkColor) } } label: { - Text("Quick Action 1") + Text(String(format: "quick_action_label".localized, 1)) .bpFont(.body) } .pickerStyle(.menu) @@ -44,7 +44,7 @@ struct GlobalSpeedSectionView: View { .foregroundStyle(theme.linkColor) } } label: { - Text("Quick Action 2") + Text(String(format: "quick_action_label".localized, 2)) .bpFont(.body) } .pickerStyle(.menu) @@ -56,7 +56,7 @@ struct GlobalSpeedSectionView: View { .foregroundStyle(theme.linkColor) } } label: { - Text("Quick Action 3") + Text(String(format: "quick_action_label".localized, 3)) .bpFont(.body) } .pickerStyle(.menu) diff --git a/BookPlayer/Settings/SettingsCompleteAccountView.swift b/BookPlayer/Settings/SettingsCompleteAccountView.swift index 6e4965820..a6fd96c1e 100644 --- a/BookPlayer/Settings/SettingsCompleteAccountView.swift +++ b/BookPlayer/Settings/SettingsCompleteAccountView.swift @@ -13,7 +13,7 @@ struct SettingsCompleteAccountView: View { var body: some View { NavigationStack { - CompleteAccountView { + CompleteAccountView(subType: .pro) { dismiss() } } diff --git a/BookPlayer/Settings/SettingsView.swift b/BookPlayer/Settings/SettingsView.swift index cdd070702..d14e9dc4f 100644 --- a/BookPlayer/Settings/SettingsView.swift +++ b/BookPlayer/Settings/SettingsView.swift @@ -178,6 +178,8 @@ struct SettingsView: View { return "p" case .pro: return "c" + case .lite: + return "l" } } diff --git a/BookPlayer/Utils/AppServices.swift b/BookPlayer/Utils/AppServices.swift index ae2c0adc0..4953fc9c2 100644 --- a/BookPlayer/Utils/AppServices.swift +++ b/BookPlayer/Utils/AppServices.swift @@ -7,6 +7,7 @@ // import AppIntents +import BackgroundTasks import BookPlayerKit import Combine import CoreData @@ -26,11 +27,24 @@ final class AppServices: BPLogger { var pendingURLActions = [Action]() - let playerState = PlayerState() + let playerState: PlayerState + /// Eager, like playerState: CarPlay registers here from connect(), which on a cold launch + /// into the car runs before CoreServices exist. Its subscription is bound in setup below. + let promptSurfaceArbiter: PromptSurfaceArbiter let reviewPromptService = ReviewPromptService() - - private init() {} + /// Reports parked sync tasks to Sentry; lives as long as the services it observes + private var syncPauseReporter: SyncPauseReporter? + /// Keeps book uploads going in the background (a continued processing task) + let uploadContinuation = UploadContinuationController() + /// The Wi-Fi-only transfer setting needs to know the current network + private let networkMonitor = NetworkMonitor() + + private init() { + let playerState = PlayerState() + self.playerState = playerState + self.promptSurfaceArbiter = PromptSurfaceArbiter(playerState: playerState) + } // MARK: - Core Services Setup @@ -77,10 +91,23 @@ final class AppServices: BPLogger { let accountService = makeAccountService(dataManager: dataManager) let audioMetadataService = makeAudioMetadataService() let libraryService = makeLibraryService(dataManager: dataManager, audioMetadataService: audioMetadataService) + let tasksDataManager = TasksDataManager() + let syncQueueService = makeSyncQueueService( + libraryService: libraryService, + getAccessLevel: { accountService.getAccessLevel() }, + verifySyncEntitlement: { await accountService.refreshSyncEntitlement() }, + tasksDataManager: tasksDataManager, + dataManager: dataManager + ) + // Not in the unit-test host: tests post `.syncTaskPaused` themselves, and those must + // never reach the real Sentry project + if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil { + syncPauseReporter = SyncPauseReporter(syncQueueService: syncQueueService) + } let syncService = makeSyncService( accountService: accountService, libraryService: libraryService, - dataManager: dataManager + syncQueueService: syncQueueService ) let playbackService = makePlaybackService(libraryService: libraryService) let playerManager = PlayerManager( @@ -89,7 +116,12 @@ final class AppServices: BPLogger { syncService: syncService, speedService: SpeedService(libraryService: libraryService), shakeMotionService: ShakeMotionService(), - widgetReloadService: WidgetReloadService() + widgetReloadService: WidgetReloadService(), + hasStreamingEnabled: { accountService.hasStreamingEnabled() }, + /// Already on main: `PlayerManager` routes every failure through `presentOnMain`. + presentFailure: { [promptSurfaceArbiter] failure in + promptSurfaceArbiter.routeFailure(failure) + } ) let watchService = PhoneWatchConnectivityService( libraryService: libraryService, @@ -102,7 +134,7 @@ final class AppServices: BPLogger { playbackService: playbackService, playerManager: playerManager ) - let hardcoverService = makeHardcoverService(libraryService: libraryService) + let hardcoverService = makeHardcoverService(libraryService: libraryService, syncService: syncService) let preferencesService = PreferencesSyncService() preferencesService.setup( @@ -112,6 +144,10 @@ final class AppServices: BPLogger { libraryService.preferencesService = preferencesService Task { await preferencesService.bootstrap() } + let externalProgressService = ExternalProgressService() + externalProgressService.setup(libraryService: libraryService, accountService: accountService) + promptSurfaceArbiter.bind(to: externalProgressService) + let coreServices = CoreServices( accountService: accountService, dataManager: dataManager, @@ -122,10 +158,13 @@ final class AppServices: BPLogger { playerManager: playerManager, preferencesService: preferencesService, syncService: syncService, + syncQueueService: syncQueueService, + externalProgressService: externalProgressService, watchService: watchService ) self.coreServices = coreServices + setupUploadContinuation(syncQueueService: syncQueueService) // Wire up accountService for Watch auth transfer watchService.setAccountService(accountService) @@ -134,6 +173,38 @@ final class AppServices: BPLogger { } } + private func setupUploadContinuation(syncQueueService: SyncQueueService) { + let networkMonitor = networkMonitor + uploadContinuation.setup(dependencies: .init( + canUpload: { syncQueueService.serverLanesEnabled && syncQueueService.accessPolicy[.uploadFile] == true }, + // Wi-Fi-only means "not over cellular", as the non-cellular session does (wired + // Ethernet counts) + networkAllowsUploads: { + UserDefaults.standard.bool(forKey: Constants.UserDefaults.allowCellularData) + || (networkMonitor.isConnected && !networkMonitor.isConnectedViaCellular) + }, + isForeground: { UIApplication.shared.applicationState == .active }, + pendingUploads: { await syncQueueService.pendingBookUploads() }, + submit: { request in + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + AppDelegate.submitBackgroundTask(request) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + }, + hasPendingRequest: { + await BGTaskScheduler.shared.pendingTaskRequests() + .contains { $0.identifier == UploadContinuationController.identifier } + }, + queueChanges: { syncQueueService.observeQueueCounts() }, + currentCounts: { syncQueueService.queueCounts } + )) + } + // MARK: - Convenience Methods func playLastBook() { @@ -223,13 +294,33 @@ final class AppServices: BPLogger { private func makeSyncService( accountService: AccountService, libraryService: LibraryService, - dataManager: DataManager + syncQueueService: SyncQueueService ) -> SyncService { let service = SyncService() service.setup( isActive: accountService.hasSyncEnabled(), libraryService: libraryService, accountService: accountService, + syncQueueService: syncQueueService, + runsMissingItemsPass: true + ) + return service + } + + private func makeSyncQueueService( + libraryService: LibraryService, + getAccessLevel: @escaping () -> AccessLevel, + verifySyncEntitlement: @escaping () async -> Bool?, + tasksDataManager: TasksDataManager, + dataManager: DataManager + ) -> SyncQueueService { + let service = SyncQueueService() + service.setup( + libraryService: libraryService, + getAccessLevel: getAccessLevel, + verifySyncEntitlement: verifySyncEntitlement, + tasksDataManager: tasksDataManager, + networkClient: NetworkClient(), dataManager: dataManager ) return service @@ -257,9 +348,9 @@ final class AppServices: BPLogger { return service } - private func makeHardcoverService(libraryService: LibraryService) -> HardcoverService { + private func makeHardcoverService(libraryService: LibraryService, syncService: SyncService) -> HardcoverService { let service = HardcoverService() - service.setup(libraryService: libraryService) + service.setup(libraryService: libraryService, syncService: syncService) return service } } diff --git a/BookPlayer/Utils/BPSKANManager.swift b/BookPlayer/Utils/BPSKANManager.swift index 376f84e99..a5e98fefc 100644 --- a/BookPlayer/Utils/BPSKANManager.swift +++ b/BookPlayer/Utils/BPSKANManager.swift @@ -41,7 +41,6 @@ class BPSKANManager: BPLogger { } /// Value received if there are low installs - @available(iOS 16.1, *) var coarseValue: SKAdNetwork.CoarseConversionValue { switch self { case .install, .import, .account: diff --git a/BookPlayer/Utils/CoreServices.swift b/BookPlayer/Utils/CoreServices.swift index aa365071c..ca52e1fe7 100644 --- a/BookPlayer/Utils/CoreServices.swift +++ b/BookPlayer/Utils/CoreServices.swift @@ -19,5 +19,7 @@ struct CoreServices { let playerManager: PlayerManager let preferencesService: PreferencesSyncService let syncService: SyncService + let syncQueueService: SyncQueueService + let externalProgressService: ExternalProgressService let watchService: PhoneWatchConnectivityService } diff --git a/BookPlayer/Utils/Extensions/Environment+BookPlayer.swift b/BookPlayer/Utils/Extensions/Environment+BookPlayer.swift index 06c60f893..0a162f88c 100644 --- a/BookPlayer/Utils/Extensions/Environment+BookPlayer.swift +++ b/BookPlayer/Utils/Extensions/Environment+BookPlayer.swift @@ -15,6 +15,9 @@ extension EnvironmentValues { @Entry var syncService: SyncService = .init() @Entry var playerLoaderService: PlayerLoaderService = .init() @Entry var playbackService: PlaybackService = .init() + @Entry var syncQueueService: SyncQueueService = .init() + /// The continued background upload task (placeholder: offers nothing until injected) + @Entry var uploadContinuation: UploadContinuationController = .init() @Entry var jellyfinService: JellyfinConnectionService = .init() @Entry var audiobookshelfService: AudiobookShelfConnectionService = .init() @Entry var hardcoverService: HardcoverService = .init() diff --git a/BookPlayer/Utils/Extensions/MediaServerProvider+Icon.swift b/BookPlayer/Utils/Extensions/MediaServerProvider+Icon.swift new file mode 100644 index 000000000..4262e6122 --- /dev/null +++ b/BookPlayer/Utils/Extensions/MediaServerProvider+Icon.swift @@ -0,0 +1,38 @@ +// +// MediaServerProvider+Icon.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import SwiftUI + +extension ExternalResource.MediaServerProvider { + /// UI concern kept in the app target: putting an ImageResource on the Shared CoreData enum + /// would couple both framework targets to the app's asset catalog. + /// + /// On `MediaServerProvider` rather than `ProviderName` because only a server earns a glyph: + /// Hardcover is progress-sync, not a source, and had no logo to return here. + var icon: ImageResource { + switch self { + case .jellyfin: .jellyfinIcon + case .audiobookshelf: .audiobookshelfIcon + } + } + + /// Height of the mark in the library row's corner badge, per provider because one number + /// cannot make these two look the same size: the symbols don't fill their boxes equally, so + /// at a shared frame height Jellyfin's ink draws ~0.4pt shorter than Audiobookshelf's and + /// reads as undersized beside it. These land both inks at ~10.7pt. + /// + /// The ceiling is not the corner's diagonal — nothing clips until well past 13. It is the + /// ink's centre climbing above the cloud's, which is what makes a media-server row look + /// misaligned against a cloud row above it. Raise these together, and not by much. + var badgeHeight: CGFloat { + switch self { + case .jellyfin: 11.5 + case .audiobookshelf: 11 + } + } +} diff --git a/BookPlayer/Utils/Extensions/Notification+BookPlayer.swift b/BookPlayer/Utils/Extensions/Notification+BookPlayer.swift index decbca539..5e8d1ae2f 100644 --- a/BookPlayer/Utils/Extensions/Notification+BookPlayer.swift +++ b/BookPlayer/Utils/Extensions/Notification+BookPlayer.swift @@ -18,4 +18,7 @@ extension Notification.Name { /// surface shares the same `AVPlayer`; the fullscreen surface takes over its video /// output, so the inline surface must reclaim rendering once fullscreen is gone. static let videoFullscreenDidDismiss = Notification.Name("\(Bundle.main.configurationString(for: .bundleIdentifier)).video.fullscreen.dismissed") + /// Posted to deep-link into Settings → Media Servers (e.g. from the missing-server + /// prompt when a synced-down external resource has no matching connection on this device). + static let showMediaServers = Notification.Name("\(Bundle.main.configurationString(for: .bundleIdentifier)).show.mediaservers") } diff --git a/BookPlayer/Utils/Extensions/View+BookPlayer.swift b/BookPlayer/Utils/Extensions/View+BookPlayer.swift index b94ab01e0..ba0040994 100644 --- a/BookPlayer/Utils/Extensions/View+BookPlayer.swift +++ b/BookPlayer/Utils/Extensions/View+BookPlayer.swift @@ -104,7 +104,7 @@ struct ItemListSearchableModifier: ViewModifier { @Binding var selectedScope: ItemListSearchScope func body(content: Content) -> some View { - if #available(iOS 26.0, *), UIDevice.current.userInterfaceIdiom == .phone { + if UIDevice.current.userInterfaceIdiom == .phone { /// New tab bar role handles searches content } else { @@ -159,17 +159,12 @@ struct MiniPlayerSafeAreaInsetModifier: ViewModifier { .safeAreaInset(edge: .bottom) { Spacer().frame(height: spacerHeight) } - } else if #available(iOS 26.0, *) { + } else { /// New accessory view already insets the entire view content .safeAreaInset(edge: .bottom) { Spacer().frame(height: keyboardObserver.isKeyboardVisible ? 0 : Spacing.M) } - } else { - content - .safeAreaInset(edge: .bottom) { - Spacer().frame(height: spacerHeight) - } } } } @@ -252,12 +247,10 @@ struct MiniPlayerModifier: ViewModifier { private func iOSBody(_ content: Content) -> some View { if #available(iOS 26.1, *) { defaultBody(content) - } else if #available(iOS 26.0, *) { + } else { content .tabBarMinimizeBehavior(.onScrollDown) .tabViewBottomAccessory(content: accessory) - } else { - defaultBody(content) } } @@ -333,12 +326,13 @@ extension View { } extension View { - @ViewBuilder + /// Glass in an explicit rounded rect rather than the default capsule, for panels that span + /// a width instead of floating as a pill. + func glassCard(cornerRadius: CGFloat = 16) -> some View { + glassEffect(in: .rect(cornerRadius: cornerRadius)) + } + func liquidGlassBackground() -> some View { - if #available(iOS 26.0, *) { - glassEffect() - } else { - background(.ultraThinMaterial) - } + glassEffect() } } diff --git a/BookPlayer/Utils/PlaybackAlerts.swift b/BookPlayer/Utils/PlaybackAlerts.swift new file mode 100644 index 000000000..4a6cb9531 --- /dev/null +++ b/BookPlayer/Utils/PlaybackAlerts.swift @@ -0,0 +1,85 @@ +// +// PlaybackAlerts.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import SwiftUI + +/// Every alert the player raises, declared once and attached twice. +/// +/// A `fullScreenCover` is its own presentation context: an alert attached beneath it cannot +/// present over it, and one attached inside it does not exist while the player is closed — +/// and playback starts (and fails) from the mini-player, CarPlay, remote commands and +/// last-book restore, not just from the open player. So both copies have to exist, and +/// `whenPlayerVisible` is what guarantees exactly one of them is ever eligible. +/// +/// Bundling them keeps the number of attachment points at two no matter how many alerts the +/// player grows. +struct PlaybackAlerts: ViewModifier { + let playerState: PlayerState + let playerManager: PlayerManager + /// Which of the two presentation contexts this copy lives in. + let whenPlayerVisible: Bool + + func body(content: Content) -> some View { + /// Read both flags in `body`, not only inside the Binding getters: with `@Observable`, the + /// thing that registers this modifier as a dependency is a read during body evaluation, + /// and these two are the only state that makes it present at all. + _ = playerState.showResumePopup + _ = playerState.pendingFailure + + return content + .alert( + "resume_playback_alert_title", + isPresented: playerState.showResumePopupBinding(whenPlayerVisible: whenPlayerVisible) + ) { + Button("yes_button") { playerManager.jumpTo(playerState.remotePlayTime ?? 0) } + Button("ignore_button", role: .cancel) {} + } message: { + Text( + String( + format: "resume_playback_alert_message".localized, + TimeParser.formatTime(playerState.remotePlayTime ?? 0) + ) + ) + } + .alert( + playerState.pendingFailure?.phoneTitle ?? "", + isPresented: playerState.pendingFailureBinding(whenPlayerVisible: whenPlayerVisible), + presenting: playerState.pendingFailure + ) { failure in + /// No `.cancel` role, matching `BPActionItem.okAction` and the CarPlay copy: the role + /// would emphasize Media Servers over OK and add outside-tap dismissal, neither of + /// which the UIKit alert this replaced did. + Button("ok_button") {} + if failure.canOfferMediaServers { + Button("media_servers_title") { + NotificationCenter.default.post(name: .showMediaServers, object: nil) + } + } + } message: { failure in + if let message = failure.phoneMessage { + Text(message) + } + } + } +} + +extension View { + func playbackAlerts( + playerState: PlayerState, + playerManager: PlayerManager, + whenPlayerVisible: Bool + ) -> some View { + modifier( + PlaybackAlerts( + playerState: playerState, + playerManager: playerManager, + whenPlayerVisible: whenPlayerVisible + ) + ) + } +} diff --git a/BookPlayer/Utils/PlayerState.swift b/BookPlayer/Utils/PlayerState.swift index e2a361f69..3d1eca8ef 100644 --- a/BookPlayer/Utils/PlayerState.swift +++ b/BookPlayer/Utils/PlayerState.swift @@ -13,7 +13,13 @@ class PlayerState { var loadedBookRelativePath: String? var showPlayer = false var isShowingPlayer = false - + var showResumePopup = false + var remotePlayTime: Double? = nil + /// The playback failure waiting to be shown, or nil. State rather than a presented alert so + /// a failure raised while the app is backgrounded survives until the user comes back — + /// the UIKit window walk this replaced simply found no key window and dropped it. + var pendingFailure: PlaybackFailure? + var showPlayerBinding: Binding { .init( get: { self.showPlayer }, @@ -27,6 +33,46 @@ class PlayerState { set: { self.isShowingPlayer = $0 } ) } + + /// The resume alert is attached in TWO places (MainView's base and the full-screen + /// player's content): an alert beneath a presented fullScreenCover cannot present over + /// it, and one inside the cover doesn't exist while the player is closed — the popup + /// fires from EVERY playback start (mini-player, CarPlay, remote commands, restore), + /// not just from the open player. Gating each copy on the cover's visibility ensures + /// exactly one of them can present. + func showResumePopupBinding(whenPlayerVisible visible: Bool) -> Binding { + .init( + get: { + // A pending failure outranks the offer: SwiftUI presents ONE alert per view, so two + // live bindings mean one is dropped with its flag still set. An error about the + // attempt the user just made is the more urgent of the two, and the offer is not + // lost — this turns true again the moment the failure is dismissed. + self.showResumePopup && self.pendingFailure == nil && self.isShowingPlayer == visible + }, + set: { self.showResumePopup = $0 } + ) + } + + /// Sibling of `showResumePopupBinding` — same two-copy reason, same gate. + func pendingFailureBinding(whenPlayerVisible visible: Bool) -> Binding { + .init( + get: { self.pendingFailure != nil && self.isShowingPlayer == visible }, + set: { presented in + if !presented { + self.pendingFailure = nil + } + } + ) + } + + /// Retires both prompts. They belong to the context that raised them and are NOT handed over + /// when the player opens or closes — see the call site in `MainView` for why that is the + /// deliberate choice rather than a workaround. + func clearPrompts() { + showResumePopup = false + remotePlayTime = nil + pendingFailure = nil + } init() {} } diff --git a/BookPlayer/Utils/RefreshTaskOperation.swift b/BookPlayer/Utils/RefreshTaskOperation.swift index c40b2f32d..b2637afc0 100644 --- a/BookPlayer/Utils/RefreshTaskOperation.swift +++ b/BookPlayer/Utils/RefreshTaskOperation.swift @@ -10,10 +10,16 @@ import BookPlayerKit import Combine import Foundation +/// Keeps a background-refresh window open until the watched queue reports drained. +/// The POLICY (which lane counts) lives at the call site: `AppDelegate.handleAppRefresh` +/// waits on the `sync` lane only — provider pushes retry forever against an unreachable +/// home server and S3 uploads already run on a background URLSession, so neither should +/// hold the process awake. +/// /// Reference: https://www.avanderlee.com/swift/asynchronous-operations/ class RefreshTaskOperation: Operation { - let syncService: SyncServiceProtocol - private var syncTasksObserver: AnyCancellable? + private let queueDrained: AnyPublisher + private var drainObserver: AnyCancellable? private let lockQueue = DispatchQueue(label: "com.bookplayer.asyncoperation.refreshtask", attributes: .concurrent) override var isAsynchronous: Bool { true } @@ -50,8 +56,15 @@ class RefreshTaskOperation: Operation { } } - init(syncService: SyncServiceProtocol) { - self.syncService = syncService + /// `finish()` is reachable from the drain sink (main) and the expiration handler + /// (a background queue); the KVO transition must happen exactly once. + private var _hasFinished = false + + /// - Parameter queueDrained: `true` while nothing the caller cares about is queued. + /// A replaying publisher (e.g. `CurrentValueSubject`) finishes an already-idle + /// refresh immediately. + init(queueDrained: AnyPublisher) { + self.queueDrained = queueDrained } override func start() { @@ -66,17 +79,21 @@ class RefreshTaskOperation: Operation { } override func main() { - syncTasksObserver = syncService.observeTasksCount().sink { [weak self] count in - guard let self else { return } + drainObserver = queueDrained.sink { [weak self] drained in + guard let self, drained else { return } - if count == 0 { - self.finish() - } + self.finish() } } func finish() { - syncTasksObserver = nil + let alreadyFinished: Bool = lockQueue.sync(flags: [.barrier]) { + defer { _hasFinished = true } + return _hasFinished + } + guard !alreadyFinished else { return } + + drainObserver = nil isExecuting = false isFinished = true } diff --git a/BookPlayer/Utils/Views/PrimaryButton.swift b/BookPlayer/Utils/Views/PrimaryButton.swift index f9155c689..292c49451 100644 --- a/BookPlayer/Utils/Views/PrimaryButton.swift +++ b/BookPlayer/Utils/Views/PrimaryButton.swift @@ -19,10 +19,12 @@ struct PrimaryButton: View { Button(action: action, label: { Text(text) }) - .buttonStyle(PrimaryButtonStyle( - background: theme.useDarkVariant ? .white : .black, - foregroundStyle: theme.useDarkVariant ? .black : .white - )) + /// The app's accent treatment, matching the Pro callout's Learn More button. It used to + /// invert black/white by theme, which was an imitation of Sign in with Apple — and an + /// unnecessary one: that button is Apple's own `SignInWithAppleButton` and gets its look + /// from `.signInWithAppleButtonStyle`, never from here. The lookalike mostly turned up on + /// sign-in screens standing next to the real thing. + .buttonStyle(PrimaryButtonStyle(background: theme.linkColor, foregroundStyle: .white)) } } @@ -33,7 +35,10 @@ struct PrimaryButtonStyle: ButtonStyle { func makeBody(configuration: Self.Configuration) -> some View { configuration.label - .bpFont(.title) + /// `.headline` (17pt semibold), not `.title` (16pt callout): the smaller size left the + /// label looking undersized in a 48pt button next to Sign in with Apple, whose text + /// scales with its height. It is also the conventional size for a full-width iOS button. + .bpFont(.headline) .frame(height: 48) .frame(maxWidth: .infinity) .background( @@ -46,7 +51,9 @@ struct PrimaryButtonStyle: ButtonStyle { ? foregroundStyle : foregroundStyle.opacity(0.5) ) - .clipShape(RoundedRectangle(cornerRadius: 24)) + /// `Capsule`, not a hardcoded radius: it tracks the height instead of silently going + /// wrong when someone changes it, and it matches the Pro callout's Learn More button. + .clipShape(Capsule()) .opacity(configuration.isPressed ? 0.4 : 1.0) } } diff --git a/BookPlayer/ar.lproj/Localizable.strings b/BookPlayer/ar.lproj/Localizable.strings index 63442e413..185a67053 100644 --- a/BookPlayer/ar.lproj/Localizable.strings +++ b/BookPlayer/ar.lproj/Localizable.strings @@ -400,7 +400,6 @@ "settings_seekprogressbar_description" = "تمكين البحث في شاشة القفل"; "donate_title" = "تبرع"; "bookmark_automatic_sleep_title" = "آخر موضع تم فيه عند تشغيل مؤقت السكون"; -"download_folder_confirmation_title" = "تحميل المجلد"; "download_folder_confirmation_message" = "تحميل جميع الملفات %d من هذا المجلد؟"; "hardcover_searching_books" = "البحث عن الكتب..."; "hardcover_unlink_button" = "إلغاء الربط"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "المهام المتزامنة (%d)"; +"sync_tasks_running_empty_title" = "لا توجد مهام قيد التشغيل"; +"sync_tasks_empty_title" = "لا توجد مهام في الانتظار"; +"sync_tasks_empty_description" = "جميع مهام المزامنة الخاصة بك محدثة."; +"sync_task_update_progress_label" = "جاري تحديث التقدم لـ %@"; +"sync_task_upload_file_label" = "جاري رفع الملف"; +"audiobook_details_narrator_label" = "رواية %@"; +"stream_button" = "بث"; +"passkey_title" = "مفتاح المرور"; +"create_profile_title" = "أنشئ ملفك الشخصي"; +"create_profile_description" = "لتمكين المزامنة السحابية، قم بتسجيل الدخول إلى ملفك الشخصي. يمكنك القيام بذلك لاحقًا من علامة تبويب الملف الشخصي."; +"not_now_button" = "ليس الآن"; +"quick_action_label" = "إجراء سريع %d"; diff --git a/BookPlayer/ca.lproj/Localizable.strings b/BookPlayer/ca.lproj/Localizable.strings index 501bd14e2..681cc96b4 100644 --- a/BookPlayer/ca.lproj/Localizable.strings +++ b/BookPlayer/ca.lproj/Localizable.strings @@ -400,7 +400,6 @@ Estem treballant dur per oferir una experiència perfecta; si és possible, pose "settings_seekprogressbar_description" = "Activa la cerca a la pantalla de bloqueig"; "donate_title" = "Donar"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Baixar carpeta"; "download_folder_confirmation_message" = "Baixar tots els %d fitxers d'aquesta carpeta?"; "hardcover_searching_books" = "Cercant llibres..."; "hardcover_unlink_button" = "Desvinculat"; @@ -504,3 +503,16 @@ Estem treballant dur per oferir una experiència perfecta; si és possible, pose "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Tasques simultànies (%d)"; +"sync_tasks_running_empty_title" = "No hi ha tasques en curs"; +"sync_tasks_empty_title" = "No hi ha tasques a la cua"; +"sync_tasks_empty_description" = "Totes les tasques de sincronització estan al dia."; +"sync_task_update_progress_label" = "S'està actualitzant el progrés de %@"; +"sync_task_upload_file_label" = "S'està pujant el fitxer"; +"audiobook_details_narrator_label" = "Narrat per %@"; +"stream_button" = "Reprodueix"; +"passkey_title" = "Clau d'accés"; +"create_profile_title" = "Crea el teu perfil"; +"create_profile_description" = "Per habilitar la sincronització al núvol, inicia sessió al teu perfil. Pots fer-ho més tard des de la pestanya Perfil."; +"not_now_button" = "Ara no"; +"quick_action_label" = "Acció ràpida %d"; diff --git a/BookPlayer/cs.lproj/Localizable.strings b/BookPlayer/cs.lproj/Localizable.strings index 1678392c9..8545044d1 100644 --- a/BookPlayer/cs.lproj/Localizable.strings +++ b/BookPlayer/cs.lproj/Localizable.strings @@ -400,7 +400,6 @@ "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Darovat"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Stáhnout složku"; "download_folder_confirmation_message" = "Stáhnout všechny %d soubory z této složky?"; "hardcover_searching_books" = "Hledám knihy..."; "hardcover_unlink_button" = "Odpojit"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Souběžné úkoly (%d)"; +"sync_tasks_running_empty_title" = "Žádné běžící úkoly"; +"sync_tasks_empty_title" = "Žádné úkoly ve frontě"; +"sync_tasks_empty_description" = "Všechny vaše synchronizační úkoly jsou aktuální."; +"sync_task_update_progress_label" = "Aktualizace průběhu pro %@"; +"sync_task_upload_file_label" = "Nahrávání souboru"; +"audiobook_details_narrator_label" = "Vypráví %@"; +"stream_button" = "Streamovat"; +"passkey_title" = "Passkey"; +"create_profile_title" = "Vytvořte si profil"; +"create_profile_description" = "Chcete-li povolit synchronizaci v cloudu, přihlaste se ke svému profilu. To můžete provést kdykoli později na kartě Profil."; +"not_now_button" = "Nyní ne"; +"quick_action_label" = "Rychlá akce %d"; diff --git a/BookPlayer/da.lproj/Localizable.strings b/BookPlayer/da.lproj/Localizable.strings index 6d8f2c993..c5d33fe8a 100644 --- a/BookPlayer/da.lproj/Localizable.strings +++ b/BookPlayer/da.lproj/Localizable.strings @@ -400,7 +400,6 @@ "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Doner"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Download mappe"; "download_folder_confirmation_message" = "Download alle %d filer fra denne mappe?"; "hardcover_searching_books" = "Søger efter bøger..."; "hardcover_unlink_button" = "Afkobl"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Samtidige opgaver (%d)"; +"sync_tasks_running_empty_title" = "Ingen aktive opgaver"; +"sync_tasks_empty_title" = "Ingen opgaver i kø"; +"sync_tasks_empty_description" = "Alle dine synkroniseringsopgaver er opdaterede."; +"sync_task_update_progress_label" = "Opdaterer fremdrift for %@"; +"sync_task_upload_file_label" = "Uploader fil"; +"audiobook_details_narrator_label" = "Indtalt af %@"; +"stream_button" = "Stream"; +"passkey_title" = "Adgangsnøgle"; +"create_profile_title" = "Opret din profil"; +"create_profile_description" = "For at aktivere skysynkronisering skal du logge ind på din profil. Du kan altid gøre dette senere fra fanen Profil."; +"not_now_button" = "Ikke nu"; +"quick_action_label" = "Hurtig handling %d"; diff --git a/BookPlayer/de.lproj/Localizable.strings b/BookPlayer/de.lproj/Localizable.strings index e4c070aaa..7c6887b0e 100644 --- a/BookPlayer/de.lproj/Localizable.strings +++ b/BookPlayer/de.lproj/Localizable.strings @@ -309,6 +309,7 @@ "choose_plan_title" = "Wähle deinen Plan"; "tasks_title" = "Aufgaben"; "queued_sync_tasks_title" = "Synchronisierungsaufgaben in der Warteschlange (%d)"; +"sync_tasks_queued_count_title" = "Warteschlangen-Aufgaben (%d)"; "sync_tasks_alert_description" = "Wenn eine Synchronisierungsaufgabe hängen bleibt und nach einer aktiven Internetverbindung nicht gelöscht werden kann, kannst du dich abmelden und wieder anmelden, um die aktuellen Aufgaben zu löschen. Dadurch wird deine Bibliothek auf den letzten mit unseren Servern synchronisierten Status zurückgesetzt. Beachte, dass Upload-Aufgaben einige Zeit in Anspruch nehmen können und der Fortschritt regelmäßig auf dem vorherigen Bildschirm angezeigt wird. @@ -400,7 +401,6 @@ "settings_seekprogressbar_description" = "Aktiviert das Navigieren in der Fortschrittsleiste auf dem Sperrbildschirm."; "donate_title" = "Spenden"; "bookmark_automatic_sleep_title" = "Letzte Position, als der Einschlaf-Timer aktiviert wurde"; -"download_folder_confirmation_title" = "Ordner herunterladen"; "download_folder_confirmation_message" = "Alle %d Dateien aus diesem Ordner herunterladen?"; "hardcover_searching_books" = "Bücher suchen..."; "hardcover_unlink_button" = "Verknüpfung lösen"; @@ -504,3 +504,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Gleichzeitige Aufgaben (%d)"; +"sync_tasks_running_empty_title" = "Keine laufenden Aufgaben"; +"sync_tasks_empty_title" = "Keine Aufgaben in der Warteschlange"; +"sync_tasks_empty_description" = "Alle Ihre Synchronisierungsaufgaben sind auf dem neuesten Stand."; +"sync_task_update_progress_label" = "Fortschritt für %@ wird aktualisiert"; +"sync_task_upload_file_label" = "Datei wird hochgeladen"; +"audiobook_details_narrator_label" = "Gelesen von %@"; +"stream_button" = "Streamen"; +"passkey_title" = "Passkey"; +"create_profile_title" = "Profil erstellen"; +"create_profile_description" = "Um die Cloud-Synchronisierung zu aktivieren, melden Sie sich bei Ihrem Profil an. Sie können dies später jederzeit im Tab „Profil“ nachholen."; +"not_now_button" = "Nicht jetzt"; +"quick_action_label" = "Schnellaktion %d"; diff --git a/BookPlayer/el.lproj/Localizable.strings b/BookPlayer/el.lproj/Localizable.strings index 8d94ea286..1fd65a4a3 100644 --- a/BookPlayer/el.lproj/Localizable.strings +++ b/BookPlayer/el.lproj/Localizable.strings @@ -400,7 +400,6 @@ "settings_seekprogressbar_description" = "Ενεργοποιήστε την αναζήτηση στην οθόνη κλειδώματος"; "donate_title" = "Προσφέρω"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Κατέβασμα φακέλου"; "download_folder_confirmation_message" = "Κατέβασμα όλων των %d αρχείων από αυτόν τον φάκελο;"; "hardcover_searching_books" = "Αναζήτηση βιβλίων..."; "hardcover_unlink_button" = "Αποσύνδεση"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Ταυτόχρονες εργασίες (%d)"; +"sync_tasks_running_empty_title" = "Δεν υπάρχουν εργασίες σε εκτέλεση"; +"sync_tasks_empty_title" = "Δεν υπάρχουν εργασίες σε αναμονή"; +"sync_tasks_empty_description" = "Όλες οι εργασίες συγχρονισμού είναι ενημερωμένες."; +"sync_task_update_progress_label" = "Ενημέρωση προόδου για %@"; +"sync_task_upload_file_label" = "Μεταφόρτωση αρχείου"; +"audiobook_details_narrator_label" = "Αφήγηση από %@"; +"stream_button" = "Μετάδοση"; +"passkey_title" = "Κλειδί πρόσβασης"; +"create_profile_title" = "Δημιουργήστε το προφίλ σας"; +"create_profile_description" = "Για να ενεργοποιήσετε τον συγχρονισμό στο cloud, συνδεθείτε στο προφίλ σας. Μπορείτε να το κάνετε αργότερα από την καρτέλα Προφίλ."; +"not_now_button" = "Όχι τώρα"; +"quick_action_label" = "Γρήγορη ενέργεια %d"; diff --git a/BookPlayer/en.lproj/Localizable.strings b/BookPlayer/en.lproj/Localizable.strings index ac6954275..395b163e6 100644 --- a/BookPlayer/en.lproj/Localizable.strings +++ b/BookPlayer/en.lproj/Localizable.strings @@ -251,6 +251,7 @@ "details_title" = "Details"; "download_title" = "Download"; "download_incomplete_error" = "The download was incomplete. Please try again."; +"download_prepare_error" = "These items couldn't be prepared for download."; "cancel_download_title" = "Cancel download"; "remove_downloaded_file_title" = "Remove from device"; "download_from_url_title" = "Download from URL"; @@ -301,6 +302,7 @@ "subscribe_title" = "Subscribe now"; "renewal_description" = "Renews automatically monthly"; "pro_welcome_title" = "Welcome to BookPlayer Pro!"; +"lite_welcome_title" = "Welcome to BookPlayer Lite!"; "pro_welcome_description" = "We'll start syncing your library when WiFi is available"; "benefits_disclaimer_watch_description" = "- Stand-alone playback on the Apple Watch is not available yet. It will be included in a future version."; "privacy_policy_title" = "Privacy Policy"; @@ -312,11 +314,30 @@ "choose_plan_title" = "Choose your plan"; "tasks_title" = "Tasks"; "queued_sync_tasks_title" = "Queued sync tasks (%d)"; +"sync_tasks_queued_count_title" = "Queued Tasks (%d)"; "sync_tasks_alert_description" = "If there's a synchronization task that is stuck and doesn't clear out after having an active internet connection, you can log out and sign in to clear out the current tasks. This will revert your library to the last state synchronized to our servers. -Keep in mind that upload tasks can take some time, and the progress will be periodically posted on the previous screen. +Keep in mind that upload tasks can take some time, and the progress will be periodically posted on the previous screen."; -We're working hard on providing a seamless experience, if possible, please contact us at support@bookplayer.app with a screenshot of this screen. Thanks!"; +"sync_tasks_concurrent_count_title" = "Concurrent Tasks (%d)"; +"sync_tasks_running_empty_title" = "No running tasks"; +"sync_tasks_empty_title" = "No queued tasks"; +"sync_tasks_empty_description" = "All your sync tasks are up to date."; +"sync_task_update_progress_label" = "Updating progress for %@"; +"sync_task_upload_file_label" = "Uploading file"; +"audiobook_details_narrator_label" = "Narrated by %@"; +"stream_button" = "Stream"; +"integrations_title" = "Integrations"; +"passkey_title" = "Passkey"; +"create_profile_title" = "Create your profile"; +"create_profile_description" = "To enable cloud sync, sign into your profile. You can always do this later from within the Profile tab"; +"not_now_button" = "Not now"; +"quick_action_label" = "Quick Action %d"; +"file_path_title" = "File Path"; +"genres_title" = "Genres"; +"overview_title" = "Overview"; +"tags_title" = "Tags"; +"series_title" = "Series"; "sort_by_size_title" = "Size"; "settings_storage_sync_deleted_description" = "Files removed (sync operations)"; "storage_sync_deleted_recover_description" = "You can import back your local books and folders if they were deleted by a sync operation. This will move all of them back to the documents folder, triggering the import process. If folders are involved, this could mean that the originals are still in the library, and some renaming will have to take place, sorry about this inconvenience"; @@ -403,8 +424,7 @@ We're working hard on providing a seamless experience, if possible, please conta "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Donate"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Download Folder"; -"download_folder_confirmation_message" = "Download all %d files from this folder?"; +"download_folder_confirmation_message" = "Download all %d files?"; "hardcover_searching_books" = "Searching books..."; "hardcover_unlink_button" = "Unlink"; "hardcover_auto_match_books" = "Auto-match books"; @@ -430,7 +450,6 @@ We're working hard on providing a seamless experience, if possible, please conta "voiceover_hardcover_navigation_hint" = "Navigate to select a book from Hardcover"; "voiceover_book_cover" = "%@ by %@, book cover"; "voiceover_loading_book_cover" = "Loading book cover"; -"integrations_title" = "Integrations"; "File Path" = "File Path"; "Genres" = "Genres"; "Overview" = "Overview"; @@ -449,6 +468,11 @@ We're working hard on providing a seamless experience, if possible, please conta "hours" = "Hours"; "minutes" = "Minutes"; +"provider_title" = "Provider"; +"provider_id_title" = "Item ID"; +"host_title" = "Host"; +"external_resources_title" = "External Resources"; + // Passkey Authentication "passkey_continue_button" = "Continue with Passkey"; "passkey_signin_button" = "Sign in with Passkey"; @@ -504,3 +528,49 @@ We're working hard on providing a seamless experience, if possible, please conta "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sort_by_title" = "Sort by"; +"sort_default_option" = "Default"; +"sort_name_option" = "Name"; +"layout_options_title" = "Layout options"; +"layout_grid_option" = "Grid"; +"layout_list_option" = "List"; +"authors_title" = "Authors"; +"import_no_audio_files_alert" = "Your server hasn't reported the audio details for these items yet. Try again once it has finished scanning them."; +"import_title" = "Import"; +"files_title" = "%d Files"; +"task_uploading_file_label" = "Uploading file"; +"task_updating_progress_label" = "Updating progress for %@"; +"yes_button" = "Yes"; +"ignore_button" = "Ignore"; +"resume_playback_alert_title" = "Resume Playback"; +"resume_playback_alert_message" = "There is another source with current play time %@. Play from this time?"; +"queue_library_sync_title" = "Library Sync"; +"queue_file_uploads_title" = "File Uploads"; +"close_title" = "Close"; +"external_sync_benefit_stream_title" = "Stream & Save Space"; +"external_sync_benefit_stream_subtitle" = "Listen instantly by streaming directly from your source. No need to download files or use up local storage."; +"external_sync_benefit_sync_title" = "Two-Way Progress Sync"; +"external_sync_benefit_sync_subtitle" = "Your position syncs both ways with Jellyfin or Audiobookshelf, and through your BookPlayer account so every device you use stays in step."; +"external_sync_disclaimer_description" = "- Your audiobook files stay on your own server, never on ours. Your subscription unlocks streaming and covers the cost of syncing your progress between devices through our servers."; +"external_sync_info_title" = "Stream & Sync"; +"queued_tasks_title" = "Queued Tasks"; +"info_title" = "Info"; +"integration_error_missing_connection" = "This book streams from your media server. Connect it in Settings to download or play it."; +"carplay_playback_failed_message" = "Can't play this book. Open BookPlayer on your phone for details."; +"carplay_missing_connection_message" = "This book streams from a media server that isn't set up on this device. Open BookPlayer on your phone to connect it."; +"sync_paused_lane_title" = "Paused: a task needs your attention"; +"sync_task_retry_button" = "Retry"; +"sync_task_report_button" = "Report"; +"sync_task_paused_voiceover" = "Paused: %@"; +"sync_paused_alert_title" = "Sync is paused until a queued task is resolved"; +"upload_file_too_large_message" = "Larger than 10 GB, so it stays on this device and isn't backed up."; +"sync_task_dismiss_button" = "Dismiss"; +"sync_tasks_need_attention_voiceover" = "Needs attention"; +"continued_upload_title" = "Uploading files"; +"continued_upload_subtitle" = "(%1$d / %2$d) %3$@"; +"continue_in_background_button" = "Continue in background"; +"continue_uploads_background_button" = "Continue uploads in background"; +"uploads_starting_title" = "Starting…"; +"uploads_running_background_title" = "Uploading in background"; +"uploads_running_background_caption" = "Uploading in background · %d%%"; +"continue_uploads_short_button" = "Continue"; diff --git a/BookPlayer/es.lproj/Localizable.strings b/BookPlayer/es.lproj/Localizable.strings index ddb81bbc5..bdbc0cfe3 100644 --- a/BookPlayer/es.lproj/Localizable.strings +++ b/BookPlayer/es.lproj/Localizable.strings @@ -309,6 +309,7 @@ "choose_plan_title" = "Elige tu plan"; "tasks_title" = "Tareas"; "queued_sync_tasks_title" = "Tareas de sincronización en cola (%d)"; +"sync_tasks_queued_count_title" = "Tareas en cola (%d)"; "sync_tasks_alert_description" = "Si hay una tarea de sincronización que está atascada y no se borra después de tener una conexión a Internet activa, puede cerrar sesión e iniciar sesión para borrar las tareas actuales. Esto revertirá su biblioteca al último estado sincronizado con nuestros servidores. Tenga en cuenta que las tareas de carga pueden llevar algún tiempo y el progreso se publicará periódicamente en la pantalla anterior. @@ -400,7 +401,6 @@ "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Donar"; "bookmark_automatic_sleep_title" = "Última posición antes de que inicie el temporizador de suspensión"; -"download_folder_confirmation_title" = "Descargar carpeta"; "download_folder_confirmation_message" = "¿Descargar todos los %d archivos de esta carpeta?"; "hardcover_searching_books" = "Buscando libros..."; "hardcover_unlink_button" = "Desvincular"; @@ -504,3 +504,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Tareas simultáneas (%d)"; +"sync_tasks_running_empty_title" = "No hay tareas en curso"; +"sync_tasks_empty_title" = "No hay tareas en cola"; +"sync_tasks_empty_description" = "Todas tus tareas de sincronización están al día."; +"sync_task_update_progress_label" = "Actualizando progreso de %@"; +"sync_task_upload_file_label" = "Subiendo archivo"; +"audiobook_details_narrator_label" = "Narrado por %@"; +"stream_button" = "Transmitir"; +"passkey_title" = "Llave de acceso"; +"create_profile_title" = "Crea tu perfil"; +"create_profile_description" = "Para habilitar la sincronización en la nube, inicia sesión en tu perfil. Siempre puedes hacerlo más tarde desde la pestaña Perfil."; +"not_now_button" = "Ahora no"; +"quick_action_label" = "Acción rápida %d"; diff --git a/BookPlayer/fi.lproj/Localizable.strings b/BookPlayer/fi.lproj/Localizable.strings index 454ffe20b..d5f263a2e 100644 --- a/BookPlayer/fi.lproj/Localizable.strings +++ b/BookPlayer/fi.lproj/Localizable.strings @@ -400,7 +400,6 @@ "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Lahjoittaa"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Lataa kansio"; "download_folder_confirmation_message" = "Ladataanko kaikki %d tiedostoa tästä kansiosta?"; "hardcover_searching_books" = "Etsitään kirjoja..."; "hardcover_unlink_button" = "Poista linkki"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Samanaikaiset tehtävät (%d)"; +"sync_tasks_running_empty_title" = "Ei käynnissä olevia tehtäviä"; +"sync_tasks_empty_title" = "Ei jonossa olevia tehtäviä"; +"sync_tasks_empty_description" = "Kaikki synkronointitehtäväsi ovat ajan tasalla."; +"sync_task_update_progress_label" = "Päivitetään edistymistä kohteelle %@"; +"sync_task_upload_file_label" = "Ladataan tiedostoa"; +"audiobook_details_narrator_label" = "Lukija: %@"; +"stream_button" = "Suoratoista"; +"passkey_title" = "Pääsyavain"; +"create_profile_title" = "Luo profiilisi"; +"create_profile_description" = "Ota pilvisynkronointi käyttöön kirjautumalla profiiliisi. Voit tehdä tämän myöhemmin Profiili-välilehdeltä."; +"not_now_button" = "Ei nyt"; +"quick_action_label" = "Pikatoiminto %d"; diff --git a/BookPlayer/fr.lproj/Localizable.strings b/BookPlayer/fr.lproj/Localizable.strings index 5fd95353f..1d143c812 100644 --- a/BookPlayer/fr.lproj/Localizable.strings +++ b/BookPlayer/fr.lproj/Localizable.strings @@ -400,7 +400,6 @@ "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Faire un don"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Télécharger le dossier"; "download_folder_confirmation_message" = "Télécharger tous les %d fichiers de ce dossier ?"; "hardcover_searching_books" = "Recherche de livres..."; "hardcover_unlink_button" = "Dissocier"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Tâches simultanées (%d)"; +"sync_tasks_running_empty_title" = "Aucune tâche en cours"; +"sync_tasks_empty_title" = "Aucune tâche en attente"; +"sync_tasks_empty_description" = "Toutes vos tâches de synchronisation sont à jour."; +"sync_task_update_progress_label" = "Mise à jour de la progression pour %@"; +"sync_task_upload_file_label" = "Chargement du fichier"; +"audiobook_details_narrator_label" = "Narré par %@"; +"stream_button" = "Diffuser"; +"passkey_title" = "Clé d'accès"; +"create_profile_title" = "Créez votre profil"; +"create_profile_description" = "Pour activer la synchronisation cloud, connectez-vous à votre profil. Vous pourrez toujours le faire plus tard via l'onglet Profil."; +"not_now_button" = "Pas maintenant"; +"quick_action_label" = "Action rapide %d"; diff --git a/BookPlayer/hu.lproj/Localizable.strings b/BookPlayer/hu.lproj/Localizable.strings index 444b8f23b..1d2c6f907 100644 --- a/BookPlayer/hu.lproj/Localizable.strings +++ b/BookPlayer/hu.lproj/Localizable.strings @@ -400,7 +400,6 @@ "donate_title" = "Adományozás"; "bookmark_automatic_sleep_title" = "Az utolsó pozíció, amikor az elalváskapcsolót bekapcsolták"; "integration_selection_count" = "%d / %d Elem"; -"download_folder_confirmation_title" = "Mappa letöltése"; "download_folder_confirmation_message" = "Letölti az összes %d fájlt ebből a mappából?"; "hardcover_searching_books" = "Könyvek keresése..."; "hardcover_unlink_button" = "Szétkapcsolás"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Párhuzamos feladatok (%d)"; +"sync_tasks_running_empty_title" = "Nincsenek futó feladatok"; +"sync_tasks_empty_title" = "Nincsenek várakozó feladatok"; +"sync_tasks_empty_description" = "Minden szinkronizálási feladata naprakész."; +"sync_task_update_progress_label" = "Haladás frissítése a következőhöz: %@"; +"sync_task_upload_file_label" = "Fájl feltöltése"; +"audiobook_details_narrator_label" = "Felolvassa: %@"; +"stream_button" = "Streamelés"; +"passkey_title" = "Jelkulcs"; +"create_profile_title" = "Hozza létre profilját"; +"create_profile_description" = "A felhőalapú szinkronizálás engedélyezéséhez jelentkezzen be profiljába. Ezt később bármikor megteheti a Profil lapon."; +"not_now_button" = "Most nem"; +"quick_action_label" = "Gyorsművelet %d"; diff --git a/BookPlayer/it.lproj/Localizable.strings b/BookPlayer/it.lproj/Localizable.strings index 1c203d3f4..6158c0d9d 100644 --- a/BookPlayer/it.lproj/Localizable.strings +++ b/BookPlayer/it.lproj/Localizable.strings @@ -400,7 +400,6 @@ "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Donare"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Scarica cartella"; "download_folder_confirmation_message" = "Scaricare tutti i %d file da questa cartella?"; "hardcover_searching_books" = "Ricerca libri..."; "hardcover_unlink_button" = "Scollega"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Attività simultanee (%d)"; +"sync_tasks_running_empty_title" = "Nessuna attività in corso"; +"sync_tasks_empty_title" = "Nessuna attività in coda"; +"sync_tasks_empty_description" = "Tutte le tue attività di sincronizzazione sono aggiornate."; +"sync_task_update_progress_label" = "Aggiornamento dei progressi per %@"; +"sync_task_upload_file_label" = "Caricamento file"; +"audiobook_details_narrator_label" = "Narrato da %@"; +"stream_button" = "Streaming"; +"passkey_title" = "Passkey"; +"create_profile_title" = "Crea il tuo profilo"; +"create_profile_description" = "Per abilitare la sincronizzazione cloud, accedi al tuo profilo. Puoi farlo in qualsiasi momento dalla scheda Profilo."; +"not_now_button" = "Non ora"; +"quick_action_label" = "Azione rapida %d"; diff --git a/BookPlayer/ja.lproj/Localizable.strings b/BookPlayer/ja.lproj/Localizable.strings index 357060dbf..df67c7198 100644 --- a/BookPlayer/ja.lproj/Localizable.strings +++ b/BookPlayer/ja.lproj/Localizable.strings @@ -400,7 +400,6 @@ "settings_seekprogressbar_description" = "ロック画面でシークを有効にします。"; "donate_title" = "寄付"; "bookmark_automatic_sleep_title" = "スリープタイマーがオンになった最後の位置"; -"download_folder_confirmation_title" = "フォルダをダウンロード"; "download_folder_confirmation_message" = "このフォルダからすべての %d ファイルをダウンロードしますか?"; "hardcover_searching_books" = "本を検索中..."; "hardcover_unlink_button" = "リンク解除"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "同時タスク (%d)"; +"sync_tasks_running_empty_title" = "実行中のタスクはありません"; +"sync_tasks_empty_title" = "待機中のタスクはありません"; +"sync_tasks_empty_description" = "すべての同期タスクは最新です。"; +"sync_task_update_progress_label" = "%@ の進捗を更新中"; +"sync_task_upload_file_label" = "ファイルをアップロード中"; +"audiobook_details_narrator_label" = "ナレーション: %@"; +"stream_button" = "ストリーム"; +"passkey_title" = "パスキー"; +"create_profile_title" = "プロフィールを作成"; +"create_profile_description" = "クラウド同期を有効にするには、プロフィールにサインインしてください。これは後でプロフィールタブからいつでも行えます。"; +"not_now_button" = "後で"; +"quick_action_label" = "クイックアクション %d"; diff --git a/BookPlayer/nb.lproj/Localizable.strings b/BookPlayer/nb.lproj/Localizable.strings index f37e327f9..51c5d9218 100644 --- a/BookPlayer/nb.lproj/Localizable.strings +++ b/BookPlayer/nb.lproj/Localizable.strings @@ -400,7 +400,6 @@ Vi jobber hardt for å gi deg en sømløs opplevelse. Hvis mulig, kontakt oss p "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Donere"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Last ned mappe"; "download_folder_confirmation_message" = "Last ned alle %d filer fra denne mappen?"; "hardcover_searching_books" = "Søker etter bøker..."; "hardcover_unlink_button" = "Koble fra"; @@ -504,3 +503,16 @@ Vi jobber hardt for å gi deg en sømløs opplevelse. Hvis mulig, kontakt oss p "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Samtidige oppgaver (%d)"; +"sync_tasks_running_empty_title" = "Ingen oppgaver kjører"; +"sync_tasks_empty_title" = "Ingen oppgaver i kø"; +"sync_tasks_empty_description" = "Alle synkroniseringsoppgavene dine er oppdatert."; +"sync_task_update_progress_label" = "Oppdaterer fremdrift for %@"; +"sync_task_upload_file_label" = "Laster opp fil"; +"audiobook_details_narrator_label" = "Lest av %@"; +"stream_button" = "Strøm"; +"passkey_title" = "Passnøkkel"; +"create_profile_title" = "Opprett din profil"; +"create_profile_description" = "Logg inn på profilen din for å aktivere skysynkronisering. Du kan alltid gjøre dette senere fra Profil-fanen."; +"not_now_button" = "Ikke nå"; +"quick_action_label" = "Hurtighandling %d"; diff --git a/BookPlayer/nl.lproj/Localizable.strings b/BookPlayer/nl.lproj/Localizable.strings index 2b4bf0c28..31cb49ecc 100644 --- a/BookPlayer/nl.lproj/Localizable.strings +++ b/BookPlayer/nl.lproj/Localizable.strings @@ -400,7 +400,6 @@ "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Doneren"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Map downloaden"; "download_folder_confirmation_message" = "Alle %d bestanden uit deze map downloaden?"; "hardcover_searching_books" = "Zoeken naar boeken..."; "hardcover_unlink_button" = "Ontkoppelen"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Gelijktijdige taken (%d)"; +"sync_tasks_running_empty_title" = "Geen lopende taken"; +"sync_tasks_empty_title" = "Geen taken in de wachtrij"; +"sync_tasks_empty_description" = "Al je synchronisatietaken zijn up-to-date."; +"sync_task_update_progress_label" = "Voortgang bijwerken voor %@"; +"sync_task_upload_file_label" = "Bestand uploaden"; +"audiobook_details_narrator_label" = "Verteld door %@"; +"stream_button" = "Streamen"; +"passkey_title" = "Toegangssleutel"; +"create_profile_title" = "Maak je profiel aan"; +"create_profile_description" = "Log in op je profiel om cloudsynchronisatie in te schakelen. Je kunt dit later altijd doen via het tabblad Profiel."; +"not_now_button" = "Niet nu"; +"quick_action_label" = "Snelle actie %d"; diff --git a/BookPlayer/pl.lproj/Localizable.strings b/BookPlayer/pl.lproj/Localizable.strings index a504e7696..281f8fcad 100644 --- a/BookPlayer/pl.lproj/Localizable.strings +++ b/BookPlayer/pl.lproj/Localizable.strings @@ -400,7 +400,6 @@ "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Podarować"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Pobierz folder"; "download_folder_confirmation_message" = "Pobrać wszystkie %d pliki z tego folderu?"; "hardcover_searching_books" = "Wyszukiwanie książek..."; "hardcover_unlink_button" = "Odłącz"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Zadania współbieżne (%d)"; +"sync_tasks_running_empty_title" = "Brak uruchomionych zadań"; +"sync_tasks_empty_title" = "Brak zadań w kolejce"; +"sync_tasks_empty_description" = "Wszystkie zadania synchronizacji są aktualne."; +"sync_task_update_progress_label" = "Aktualizacja postępu dla %@"; +"sync_task_upload_file_label" = "Przesyłanie pliku"; +"audiobook_details_narrator_label" = "Czyta %@"; +"stream_button" = "Strumieniuj"; +"passkey_title" = "Klucz dostępu"; +"create_profile_title" = "Utwórz swój profil"; +"create_profile_description" = "Aby włączyć synchronizację w chmurze, zaloguj się do swojego profilu. Możesz to zrobić później w zakładce Profil."; +"not_now_button" = "Nie teraz"; +"quick_action_label" = "Szybka akcja %d"; diff --git a/BookPlayer/pt-BR.lproj/Localizable.strings b/BookPlayer/pt-BR.lproj/Localizable.strings index a6229d3c4..abc607df5 100644 --- a/BookPlayer/pt-BR.lproj/Localizable.strings +++ b/BookPlayer/pt-BR.lproj/Localizable.strings @@ -309,6 +309,7 @@ "choose_plan_title" = "Escolha seu plano"; "tasks_title" = "Tarefas"; "queued_sync_tasks_title" = "Tarefas de sincronização em fila (%d)"; +"sync_tasks_queued_count_title" = "Tarefas em fila (%d)"; "sync_tasks_alert_description" = "Se houver uma tarefa de sincronização que está travada e não é eliminada após uma conexão ativa com a Internet, você pode fazer logout e entrar para limpar as tarefas atuais. Isso reverterá sua biblioteca para o último estado sincronizado com nossos servidores. Lembre-se de que as tarefas de upload podem levar algum tempo e o progresso será postado periodicamente na tela anterior. @@ -400,7 +401,6 @@ "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Doar"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Baixar pasta"; "download_folder_confirmation_message" = "Baixar todos os %d arquivos desta pasta?"; "hardcover_searching_books" = "Procurando livros..."; "hardcover_unlink_button" = "Desvincular"; @@ -504,3 +504,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Tarefas Simultâneas (%d)"; +"sync_tasks_running_empty_title" = "Nenhuma tarefa em execução"; +"sync_tasks_empty_title" = "Nenhuma tarefa na fila"; +"sync_tasks_empty_description" = "Todas as suas tarefas de sincronização estão em dia."; +"sync_task_update_progress_label" = "Atualizando progresso para %@"; +"sync_task_upload_file_label" = "Enviando arquivo"; +"audiobook_details_narrator_label" = "Narrado por %@"; +"stream_button" = "Transmitir"; +"passkey_title" = "Chave de Acesso"; +"create_profile_title" = "Crie seu perfil"; +"create_profile_description" = "Para ativar a sincronização na nuvem, entre no seu perfil. Você pode fazer isso mais tarde na aba Perfil."; +"not_now_button" = "Agora não"; +"quick_action_label" = "Ação Rápida %d"; diff --git a/BookPlayer/pt-PT.lproj/Localizable.strings b/BookPlayer/pt-PT.lproj/Localizable.strings index 4a3bfd468..0edc09ab0 100644 --- a/BookPlayer/pt-PT.lproj/Localizable.strings +++ b/BookPlayer/pt-PT.lproj/Localizable.strings @@ -309,6 +309,7 @@ "choose_plan_title" = "Escolha o seu plano"; "tasks_title" = "Tarefas"; "queued_sync_tasks_title" = "Tarefas de sincronização em fila (%d)"; +"sync_tasks_queued_count_title" = "Tarefas em fila (%d)"; "sync_tasks_alert_description" = "Se houver uma tarefa de sincronização que está bloquada e não é eliminada após uma ligação activa à Internet, você pode fazer logout e login novamente para limpar as tarefas actuais. Isso irá repor a sua biblioteca para o último estado sincronizado com nossos servidores. Tenha em atenção de que as tarefas de upload poderão levar algum tempo e o progresso será notificado periodicamente no ecrã anterior. @@ -400,7 +401,6 @@ "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Doar"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Transferir pasta"; "download_folder_confirmation_message" = "Transferir todos os %d ficheiros desta pasta?"; "hardcover_searching_books" = "A procurar livros..."; "hardcover_unlink_button" = "Desvincular"; @@ -504,3 +504,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Tarefas Simultâneas (%d)"; +"sync_tasks_running_empty_title" = "Nenhuma tarefa em execução"; +"sync_tasks_empty_title" = "Nenhuma tarefa na fila"; +"sync_tasks_empty_description" = "Todas as suas tarefas de sincronização estão em dia."; +"sync_task_update_progress_label" = "A atualizar o progresso de %@"; +"sync_task_upload_file_label" = "A enviar ficheiro"; +"audiobook_details_narrator_label" = "Narrado por %@"; +"stream_button" = "Transmitir"; +"passkey_title" = "Chave de Acesso"; +"create_profile_title" = "Crie o seu perfil"; +"create_profile_description" = "Para ativar a sincronização na nuvem, inicie sessão no seu perfil. Pode fazê-lo mais tarde no separador Perfil."; +"not_now_button" = "Agora não"; +"quick_action_label" = "Ação Rápida %d"; diff --git a/BookPlayer/ro.lproj/Localizable.strings b/BookPlayer/ro.lproj/Localizable.strings index c6fc950c2..ea324b8ff 100644 --- a/BookPlayer/ro.lproj/Localizable.strings +++ b/BookPlayer/ro.lproj/Localizable.strings @@ -400,7 +400,6 @@ "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Dona"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Descarcă dosarul"; "download_folder_confirmation_message" = "Descarcă toate cele %d fișiere din acest dosar?"; "hardcover_searching_books" = "Căutând cărți..."; "hardcover_unlink_button" = "Deconectează"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Sarcini simultane (%d)"; +"sync_tasks_running_empty_title" = "Nicio sarcină în curs"; +"sync_tasks_empty_title" = "Nicio sarcină în coadă"; +"sync_tasks_empty_description" = "Toate sarcinile de sincronizare sunt la zi."; +"sync_task_update_progress_label" = "Se actualizează progresul pentru %@"; +"sync_task_upload_file_label" = "Se încarcă fișierul"; +"audiobook_details_narrator_label" = "Narat de %@"; +"stream_button" = "Redare"; +"passkey_title" = "Cheie de acces"; +"create_profile_title" = "Creați-vă profilul"; +"create_profile_description" = "Pentru a activa sincronizarea în cloud, conectați-vă la profilul dvs. Puteți face acest lucru mai târziu din fila Profil."; +"not_now_button" = "Nu acum"; +"quick_action_label" = "Acțiune rapidă %d"; diff --git a/BookPlayer/ru.lproj/Localizable.strings b/BookPlayer/ru.lproj/Localizable.strings index 358ab227c..e86ca249a 100644 --- a/BookPlayer/ru.lproj/Localizable.strings +++ b/BookPlayer/ru.lproj/Localizable.strings @@ -400,7 +400,6 @@ "settings_seekprogressbar_description" = "Включить возможность перемотки на экране блокировки"; "donate_title" = "Пожертвовать"; "bookmark_automatic_sleep_title" = "Последняя позиция, когда был включен таймер сна"; -"download_folder_confirmation_title" = "Скачать папку"; "download_folder_confirmation_message" = "Скачать все %d файлов из этой папки?"; "hardcover_searching_books" = "Поиск книг..."; "hardcover_unlink_button" = "Отвязать"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Параллельные задачи (%d)"; +"sync_tasks_running_empty_title" = "Нет запущенных задач"; +"sync_tasks_empty_title" = "В очереди нет задач"; +"sync_tasks_empty_description" = "Все задачи синхронизации выполнены."; +"sync_task_update_progress_label" = "Обновление прогресса для %@"; +"sync_task_upload_file_label" = "Загрузка файла"; +"audiobook_details_narrator_label" = "Текст читает %@"; +"stream_button" = "Транслировать"; +"passkey_title" = "Ключ доступа"; +"create_profile_title" = "Создайте свой профиль"; +"create_profile_description" = "Чтобы включить облачную синхронизацию, войдите в свой профиль. Вы всегда можете сделать это позже во вкладке «Профиль»."; +"not_now_button" = "Не сейчас"; +"quick_action_label" = "Быстрое действие %d"; diff --git a/BookPlayer/sk-SK.lproj/Localizable.strings b/BookPlayer/sk-SK.lproj/Localizable.strings index d04b70474..ebf6b4562 100644 --- a/BookPlayer/sk-SK.lproj/Localizable.strings +++ b/BookPlayer/sk-SK.lproj/Localizable.strings @@ -400,7 +400,6 @@ Usilovne pracujeme na poskytovaní bezproblémového zážitku, ak je to možné "settings_seekprogressbar_description" = "Povolenie vyhľadávania pri zamknutej obrazovke"; "donate_title" = "Darovať"; "bookmark_automatic_sleep_title" = "Posledná pozícia, keď bol časovač spánku zapnutý"; -"download_folder_confirmation_title" = "Stiahnuť priečinok"; "download_folder_confirmation_message" = "Stiahnuť všetkých %d súbory z tohto priečinku?"; "hardcover_searching_books" = "Hľadanie kníh..."; "hardcover_unlink_button" = "Odpojiť"; @@ -504,3 +503,16 @@ Usilovne pracujeme na poskytovaní bezproblémového zážitku, ak je to možné "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Súbežné úlohy (%d)"; +"sync_tasks_running_empty_title" = "Žiadne bežiace úlohy"; +"sync_tasks_empty_title" = "Žiadne úlohy v poradí"; +"sync_tasks_empty_description" = "Všetky vaše synchronizačné úlohy sú aktuálne."; +"sync_task_update_progress_label" = "Aktualizácia priebehu pre %@"; +"sync_task_upload_file_label" = "Nahrávanie súboru"; +"audiobook_details_narrator_label" = "Rozpráva %@"; +"stream_button" = "Streamovať"; +"passkey_title" = "Prístupový kľúč"; +"create_profile_title" = "Vytvorte si profil"; +"create_profile_description" = "Ak chcete povoliť synchronizáciu v cloude, prihláste sa do svojho profilu. Môžete to urobiť kedykoľvek neskôr na karte Profil."; +"not_now_button" = "Teraz nie"; +"quick_action_label" = "Rýchla akcia %d"; diff --git a/BookPlayer/sv.lproj/Localizable.strings b/BookPlayer/sv.lproj/Localizable.strings index f9c8b7b33..1bae71c90 100644 --- a/BookPlayer/sv.lproj/Localizable.strings +++ b/BookPlayer/sv.lproj/Localizable.strings @@ -400,7 +400,6 @@ "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Donera"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Ladda ner mapp"; "download_folder_confirmation_message" = "Ladda ner alla %d filer från denna mapp?"; "hardcover_searching_books" = "Söker böcker..."; "hardcover_unlink_button" = "Koppla från"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Samtidiga uppgifter (%d)"; +"sync_tasks_running_empty_title" = "Inga pågående uppgifter"; +"sync_tasks_empty_title" = "Inga uppgifter i kön"; +"sync_tasks_empty_description" = "Alla dina synkroniseringsuppgifter är uppdaterade."; +"sync_task_update_progress_label" = "Uppdaterar framsteg för %@"; +"sync_task_upload_file_label" = "Laddar upp fil"; +"audiobook_details_narrator_label" = "Uppläst av %@"; +"stream_button" = "Streama"; +"passkey_title" = "Nyckelkod"; +"create_profile_title" = "Skapa din profil"; +"create_profile_description" = "För att aktivera molnsynkronisering, logga in på din profil. Du kan alltid göra detta senare från Profil-fliken."; +"not_now_button" = "Inte nu"; +"quick_action_label" = "Snabbåtgärd %d"; diff --git a/BookPlayer/tr.lproj/Localizable.strings b/BookPlayer/tr.lproj/Localizable.strings index 755d74f47..5666e04ce 100644 --- a/BookPlayer/tr.lproj/Localizable.strings +++ b/BookPlayer/tr.lproj/Localizable.strings @@ -400,7 +400,6 @@ "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Bağış yapmak"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Klasörü indir"; "download_folder_confirmation_message" = "Bu klasördeki tüm %d dosya indirilsin mi?"; "hardcover_searching_books" = "Kitaplar aranıyor..."; "hardcover_unlink_button" = "Bağlantıyı kes"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Eşzamanlı Görevler (%d)"; +"sync_tasks_running_empty_title" = "Çalışan görev yok"; +"sync_tasks_empty_title" = "Sırada bekleyen görev yok"; +"sync_tasks_empty_description" = "Tüm senkronizasyon görevleriniz güncel."; +"sync_task_update_progress_label" = "%@ için ilerleme güncelleniyor"; +"sync_task_upload_file_label" = "Dosya yükleniyor"; +"audiobook_details_narrator_label" = "Seslendiren: %@"; +"stream_button" = "Yayınla"; +"passkey_title" = "Geçiş Anahtarı"; +"create_profile_title" = "Profilinizi oluşturun"; +"create_profile_description" = "Bulut senkronizasyonunu etkinleştirmek için profilinize giriş yapın. Bunu daha sonra Profil sekmesinden istediğiniz zaman yapabilirsiniz."; +"not_now_button" = "Şimdi değil"; +"quick_action_label" = "Hızlı Eylem %d"; diff --git a/BookPlayer/uk.lproj/Localizable.strings b/BookPlayer/uk.lproj/Localizable.strings index 5bffda43b..34b17187c 100644 --- a/BookPlayer/uk.lproj/Localizable.strings +++ b/BookPlayer/uk.lproj/Localizable.strings @@ -400,7 +400,6 @@ "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "Пожертвуйте"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "Завантажити папку"; "download_folder_confirmation_message" = "Завантажити всі %d файлів з цієї папки?"; "hardcover_searching_books" = "Пошук книг..."; "hardcover_unlink_button" = "Від'єднати"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "Паралельні завдання (%d)"; +"sync_tasks_running_empty_title" = "Немає запущених завдань"; +"sync_tasks_empty_title" = "Немає завдань у черзі"; +"sync_tasks_empty_description" = "Усі ваші завдання синхронізації виконані."; +"sync_task_update_progress_label" = "Оновлення прогресу для %@"; +"sync_task_upload_file_label" = "Завантаження файлу"; +"audiobook_details_narrator_label" = "Читає %@"; +"stream_button" = "Транслювати"; +"passkey_title" = "Ключ доступу"; +"create_profile_title" = "Створіть свій профіль"; +"create_profile_description" = "Щоб увімкнути хмарну синхронізацію, увійдіть у свій профіль. Ви завжди можете зробити це пізніше у вкладці «Профіль»."; +"not_now_button" = "Не зараз"; +"quick_action_label" = "Швидка дія %d"; diff --git a/BookPlayer/zh-Hans.lproj/Localizable.strings b/BookPlayer/zh-Hans.lproj/Localizable.strings index f079faa85..7ec966988 100644 --- a/BookPlayer/zh-Hans.lproj/Localizable.strings +++ b/BookPlayer/zh-Hans.lproj/Localizable.strings @@ -400,7 +400,6 @@ "settings_seekprogressbar_description" = "Enable seeking on the lock screen"; "donate_title" = "捐"; "bookmark_automatic_sleep_title" = "Last position when the sleep timer was turned on"; -"download_folder_confirmation_title" = "下载文件夹"; "download_folder_confirmation_message" = "下载此文件夹中的所有 %d 个文件?"; "hardcover_searching_books" = "正在搜索图书..."; "hardcover_unlink_button" = "取消链接"; @@ -504,3 +503,16 @@ "jellyfin_quick_connect_error_no_code" = "Your Jellyfin server didn't return a Quick Connect code. Make sure Quick Connect is enabled in the server's dashboard."; "jellyfin_quick_connect_error_generic" = "Quick Connect couldn't complete. Check that Quick Connect is enabled on your server and try again."; "jellyfin_quick_connect_error_timeout" = "Timed out waiting for the code to be entered. Please try again."; +"sync_tasks_concurrent_count_title" = "并发任务 (%d)"; +"sync_tasks_running_empty_title" = "无正在运行的任务"; +"sync_tasks_empty_title" = "无排队任务"; +"sync_tasks_empty_description" = "您的所有同步任务均已完成。"; +"sync_task_update_progress_label" = "正在更新 %@ 的进度"; +"sync_task_upload_file_label" = "正在上传文件"; +"audiobook_details_narrator_label" = "讲述人:%@"; +"stream_button" = "流式传输"; +"passkey_title" = "通行密钥"; +"create_profile_title" = "创建您的个人资料"; +"create_profile_description" = "要启用云同步,请登录您的个人资料。您稍后可以随时在“个人资料”选项卡中执行此操作。"; +"not_now_button" = "以后再说"; +"quick_action_label" = "快速操作 %d"; diff --git a/BookPlayerIntents/PlayAndSleepActionHandler.swift b/BookPlayerIntents/PlayAndSleepActionHandler.swift index b1e5e44ee..8401f9b72 100644 --- a/BookPlayerIntents/PlayAndSleepActionHandler.swift +++ b/BookPlayerIntents/PlayAndSleepActionHandler.swift @@ -15,7 +15,6 @@ class PlayAndSleepActionHandler: NSObject, PlayAndSleepActionIntentHandling { completion(PlayAndSleepActionIntentResponse(code: .continueInApp, userActivity: nil)) } - @available(iOSApplicationExtension 13.0, *) func resolveSleepTimer(for intent: PlayAndSleepActionIntent, with completion: @escaping (TimerOptionResolutionResult) -> Void) { if intent.sleepTimer == .unknown { completion(TimerOptionResolutionResult.needsValue()) @@ -24,7 +23,6 @@ class PlayAndSleepActionHandler: NSObject, PlayAndSleepActionIntentHandling { } } - @available(iOSApplicationExtension 13.0, *) func resolveAutoplay(for intent: PlayAndSleepActionIntent, with completion: @escaping (INBooleanResolutionResult) -> Void) { completion(INBooleanResolutionResult.notRequired()) } diff --git a/BookPlayerIntents/SleepTimerHandler.swift b/BookPlayerIntents/SleepTimerHandler.swift index fdc47614c..7fa25cfc4 100644 --- a/BookPlayerIntents/SleepTimerHandler.swift +++ b/BookPlayerIntents/SleepTimerHandler.swift @@ -14,7 +14,6 @@ class SleepTimerHandler: NSObject, SleepTimerIntentHandling { completion(SleepTimerIntentResponse(code: .continueInApp, userActivity: nil)) } - @available(iOSApplicationExtension 13.0, *) func resolveOption(for intent: SleepTimerIntent, with completion: @escaping (TimerOptionResolutionResult) -> Void) { if intent.option == .unknown { completion(TimerOptionResolutionResult.needsValue()) @@ -23,7 +22,6 @@ class SleepTimerHandler: NSObject, SleepTimerIntentHandling { } } - @available(iOSApplicationExtension 13.0, *) func resolveSeconds(for intent: SleepTimerIntent, with completion: @escaping (SleepTimerSecondsResolutionResult) -> Void) { completion(SleepTimerSecondsResolutionResult.notRequired()) } diff --git a/BookPlayerTests/DataManagerTests.swift b/BookPlayerTests/DataManagerTests.swift index 102a32af9..e4c2567d6 100644 --- a/BookPlayerTests/DataManagerTests.swift +++ b/BookPlayerTests/DataManagerTests.swift @@ -9,6 +9,7 @@ @testable import BookPlayer @testable import BookPlayerKit import Combine +import CoreData import XCTest class DataManagerTests: XCTestCase { @@ -62,3 +63,60 @@ class ProcessFilesTests: DataManagerTests { wait(for: [expectation], timeout: 15) } } + +/// Guards the v11→v12 manual migration: `shouldInferMappingModelAutomatically` is OFF, +/// so if the v12 `.xcdatamodel` is ever edited after `MappingModel_v11_to_v12` was +/// generated, the checksum mismatch makes `migrateStore` throw at launch for every +/// existing install. This must fail in CI first. +class MigrationV11ToV12Tests: XCTestCase { + func testMappingModelMatchesModelsAndMigratesStore() throws { + let v11 = DBVersion.v11.model() + let v12 = DBVersion.v12.model() + XCTAssertFalse(v11.entities.isEmpty, "v11 model failed to load from the bundle") + XCTAssertFalse(v12.entities.isEmpty, "v12 model failed to load from the bundle") + XCTAssertTrue(v12.entitiesByName.keys.contains("ExternalResource")) + + // The lookup validates BOTH sides' entity checksums — this is exactly what + // breaks when a model is edited after its mapping model was generated + let mapping = NSMappingModel(from: [Bundle.main], forSourceModel: v11, destinationModel: v12) + let unwrappedMapping = try XCTUnwrap(mapping, "MappingModel_v11_to_v12 no longer matches the v11/v12 models") + + // End-to-end: run a real v11 store through the migration and verify the + // result opens as a v12 store + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let sourceURL = tempDir.appendingPathComponent("v11.sqlite") + let destinationURL = tempDir.appendingPathComponent("v12.sqlite") + + let coordinator = NSPersistentStoreCoordinator(managedObjectModel: v11) + let store = try coordinator.addPersistentStore( + ofType: NSSQLiteStoreType, + configurationName: nil, + at: sourceURL, + options: nil + ) + try coordinator.remove(store) + + let manager = NSMigrationManager(sourceModel: v11, destinationModel: v12) + try manager.migrateStore( + from: sourceURL, + sourceType: NSSQLiteStoreType, + options: nil, + with: unwrappedMapping, + toDestinationURL: destinationURL, + destinationType: NSSQLiteStoreType, + destinationOptions: nil + ) + + let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore( + ofType: NSSQLiteStoreType, + at: destinationURL, + options: nil + ) + XCTAssertTrue( + v12.isConfiguration(withName: nil, compatibleWithStoreMetadata: metadata), + "migrated store is not compatible with the v12 model" + ) + } +} diff --git a/BookPlayerTests/ImportOperationTests.swift b/BookPlayerTests/ImportOperationTests.swift index bcc3f4fee..a576f929d 100644 --- a/BookPlayerTests/ImportOperationTests.swift +++ b/BookPlayerTests/ImportOperationTests.swift @@ -8,6 +8,7 @@ @testable import BookPlayer @testable import BookPlayerKit +import Combine import XCTest // MARK: - processFiles() @@ -128,3 +129,1630 @@ class ImportOperationTests: XCTestCase { wait(for: [promise], timeout: 15) } } + +// MARK: - Virtual import pipeline + +@MainActor +final class VirtualImportPipelineTests: XCTestCase { + private struct StubItem { + let id: String + } + + private func makeResource(id: String) -> SimpleExternalResource { + SimpleExternalResource( + providerName: "jellyfin", + providerId: id, + syncStatus: ExternalResource.SyncStatus.stream.rawValue, + lastSyncedAt: nil, + libraryItem: nil + ) + } + + private func hydrated(_ fileExtension: String, duration: TimeInterval = 4800) -> HydratedItem? { + HydratedItem(fileExtension: fileExtension, duration: duration) + } + + func testBuildsOnlyHydratedItemsInSelectionOrder() async throws { + let items = [StubItem(id: "a"), StubItem(id: "b"), StubItem(id: "c")] + let resources = try await VirtualImportPipeline.run( + items: items, + id: \.id, + hydrate: { ids in + XCTAssertEqual(ids, ["a", "b", "c"]) + var hydratedByID: [String: HydratedItem] = [:] + hydratedByID["a"] = self.hydrated("m4b") + hydratedByID["c"] = self.hydrated("mp3") + return hydratedByID // "b" reports no audio-file metadata + }, + buildResource: { item, _ in self.makeResource(id: item.id) } + ) + XCTAssertEqual(resources.map(\.providerId), ["a", "c"], "skips unhydrated items, keeps selection order") + } + + /// Drives the drop through the REAL provider mapping rather than a stub, so it fails if a + /// closure ever starts reading a source that reports "unmeasured" as something other than nil. + func testSkipsItemsTheServerNeverMeasured() async throws { + let measured = AudiobookShelfLibraryItem( + id: "measured", + title: "Measured", + kind: .audiobook, + libraryId: "lib", + duration: 4800, + fileExtension: "m4b" + ) + let unmeasured = AudiobookShelfLibraryItem( + id: "unmeasured", + title: "Unmeasured", + kind: .audiobook, + libraryId: "lib", + duration: nil, + fileExtension: "m4b" + ) + + let resources = try await VirtualImportPipeline.run( + items: [measured, unmeasured], + id: \.id, + hydrate: { _ in + [measured, unmeasured].reduce(into: [:]) { + $0[$1.id] = HydratedItem(fileExtension: $1.fileExtension, duration: $1.duration) + } + }, + buildResource: { item, _ in self.makeResource(id: item.id) } + ) + + XCTAssertEqual( + resources.map(\.providerId), + ["measured"], + "an item with no server-measured length would import a row that can never play" + ) + } + + func testHydratedItemRequiresARealExtensionAndAMeasuredLength() { + XCTAssertNotNil(HydratedItem(fileExtension: "m4b", duration: 1)) + + XCTAssertNil(HydratedItem(fileExtension: nil, duration: 4800)) + XCTAssertNil(HydratedItem(fileExtension: "", duration: 4800)) + XCTAssertNil(HydratedItem(fileExtension: "m4b", duration: nil)) + /// Jellyfin's mapper collapses an unprobed runtime to 0 rather than nil, so the + /// gate has to be `> 0`, not `!= nil` + XCTAssertNil(HydratedItem(fileExtension: "m4b", duration: 0)) + XCTAssertNil(HydratedItem(fileExtension: "m4b", duration: -1)) + } + + func testEmptySelectionNeverHydrates() async throws { + var hydrateCalled = false + let resources = try await VirtualImportPipeline.run( + items: [StubItem](), + id: \.id, + hydrate: { _ in + hydrateCalled = true + return [:] + }, + buildResource: { item, _ in self.makeResource(id: item.id) } + ) + XCTAssertTrue(resources.isEmpty) + XCTAssertFalse(hydrateCalled, "no selection means no network round-trip") + } + + func testHydrationErrorsPropagate() async { + do { + _ = try await VirtualImportPipeline.run( + items: [StubItem(id: "a")], + id: \.id, + hydrate: { _ in throw URLError(.notConnectedToInternet) }, + buildResource: { item, _ in self.makeResource(id: item.id) } + ) + XCTFail("expected the hydration error to propagate to the caller's error state") + } catch { + XCTAssertEqual((error as? URLError)?.code, .notConnectedToInternet) + } + } +} + +// MARK: - External import confirmation + +@MainActor +final class ExternalImportViewModelTests: XCTestCase { + private func makeBatch(ids: [String]) -> ExternalImportBatch { + ExternalImportBatch( + resources: ids.map { + SimpleExternalResource( + providerName: "jellyfin", + providerId: $0, + syncStatus: ExternalResource.SyncStatus.stream.rawValue, + lastSyncedAt: nil, + libraryItem: nil + ) + } + ) + } + + func testRemovalMutatesOwnedStateAndPublishes() { + let sut = ExternalImportViewModel(batch: makeBatch(ids: ["a", "b"]), onConfirm: { _ in }) + var published = false + let subscription = sut.objectWillChange.sink { published = true } + + sut.removeResource(withId: "a") + + XCTAssertEqual(sut.resources.map(\.providerId), ["b"]) + XCTAssertTrue(published, "removal must republish — the old mailbox passthrough left the delete button visually dead") + subscription.cancel() + } + + func testConfirmHandsBackTheEditedSelection() { + var confirmed: [SimpleExternalResource]? + let sut = ExternalImportViewModel(batch: makeBatch(ids: ["a", "b"]), onConfirm: { confirmed = $0 }) + + sut.removeResource(withId: "b") + sut.confirm() + + XCTAssertEqual(confirmed?.map(\.providerId), ["a"], "confirm sends the batch as edited, not as staged") + } + + /// The shell-row mapping: id carries the provider identity (removal is keyed on + /// it), the title is the real filename, and a resource with no library item falls + /// back to the unknown-title copy instead of rendering empty. + func testConfirmationRowsMapIdentityFilenameAndFallback() { + let named = SimpleExternalResource( + providerName: "jellyfin", + providerId: "row-1", + syncStatus: ExternalResource.SyncStatus.stream.rawValue, + lastSyncedAt: nil, + libraryItem: SimpleLibraryItem( + title: "Book", + details: "Author", + speed: 1.0, + currentTime: 0, + duration: 10, + percentCompleted: 0, + isFinished: false, + relativePath: "row-1-book.m4b", + remoteURL: nil, + artworkURL: nil, + orderRank: 0, + parentFolder: nil, + originalFileName: "book.m4b", + lastPlayDate: nil, + type: .book, + uuid: "uuid-1" + ) + ) + let orphan = SimpleExternalResource( + providerName: "jellyfin", + providerId: "row-2", + syncStatus: ExternalResource.SyncStatus.stream.rawValue, + lastSyncedAt: nil, + libraryItem: nil + ) + let sut = ExternalImportViewModel( + batch: ExternalImportBatch(resources: [named, orphan]), + onConfirm: { _ in } + ) + + let rows = sut.confirmationRows + + XCTAssertEqual(rows.map(\.id), ["row-1", "row-2"]) + XCTAssertEqual(rows.first?.title, "book.m4b") + XCTAssertEqual(rows.last?.title, "voiceover_unknown_title".localized) + XCTAssertEqual(rows.map(\.icon), ["waveform", "waveform"]) + } +} + +// MARK: - Confirm destinations (bulk vs details) + +@MainActor +final class ExternalImportConfirmDestinationTests: XCTestCase { + private func makeResources() -> [SimpleExternalResource] { + [SimpleExternalResource( + providerName: "jellyfin", + providerId: "confirm-1", + syncStatus: ExternalResource.SyncStatus.stream.rawValue, + lastSyncedAt: nil, + libraryItem: nil + )] + } + + func testBulkConfirmSendsBatchThenDismissesBrowser() { + var sent: [SimpleExternalResource]? + var dismissed = false + let navigation = BPNavigation() + navigation.dismiss = { dismissed = true } + + let sut = JellyfinLibraryViewModel( + folderID: "folder-1", + connectionService: JellyfinConnectionService(), + singleFileDownloadService: SingleFileDownloadService(networkClient: NetworkClient()), + onImportConfirmed: { sent = $0 }, + accountService: AccountService(), + navigation: navigation, + navigationTitle: "Library" + ) + + sut.confirmExternalImport(makeResources()) + + XCTAssertEqual(sent?.map(\.providerId), ["confirm-1"]) + XCTAssertTrue(dismissed, "bulk confirm closes the browser — the batch lands in the library") + } + + func testDetailsConfirmSendsWithoutDismissing() { + var sent: [SimpleExternalResource]? + var dismissed = false + let navigation = BPNavigation() + navigation.dismiss = { dismissed = true } + + let sut = JellyfinAudiobookDetailsViewModel( + item: JellyfinLibraryItem(id: "item-1", name: "Book", kind: .audiobook), + connectionService: JellyfinConnectionService(), + singleFileDownloadService: SingleFileDownloadService(networkClient: NetworkClient()), + accountService: AccountService(), + onImportConfirmed: { sent = $0 }, + navigation: navigation, + navigationTitle: "Book" + ) + + sut.confirmExternalImport(makeResources()) + + XCTAssertEqual(sent?.map(\.providerId), ["confirm-1"]) + XCTAssertFalse(dismissed, "details confirm keeps you in the browser for serial importing") + } +} + +// MARK: - AudiobookShelf payload decoding + +@MainActor +final class AudiobookShelfDecodingTests: XCTestCase { + /// Real-shaped expanded payload: the server's AudioFile.toJSON nests filename/ext + /// under `metadata`, and `ext` arrives dot-prefixed (".m4b"). Pinned as a JSON + /// fixture because the previous decoder expected top-level fields — it could never + /// decode a live server response, and builder-based tests couldn't catch that. + func testBatchGetPayloadDecodesNestedAudioFileMetadataAndStripsDot() throws { + let json = Data(""" + { + "libraryItems": [ + { + "id": "li_1", + "libraryId": "lib_1", + "mediaType": "book", + "media": { + "metadata": { "title": "Real Book" }, + "duration": 4800, + "chapters": [ + { "start": 0, "end": 600, "title": "Chapter One" }, + { "start": 600, "end": 4800, "title": "Chapter Two" } + ], + "audioFiles": [ + { + "index": 1, + "ino": "123", + "metadata": { + "filename": "Real Book.m4b", + "ext": ".m4b", + "path": "/audiobooks/Real Book.m4b" + }, + "addedAt": 1 + } + ] + } + } + ] + } + """.utf8) + + let decoded = try JSONDecoder().decode(AudiobookShelfBatchItemsResponse.self, from: json) + let items = (decoded.libraryItems ?? []).compactMap { AudiobookShelfLibraryItem(apiItem: $0) } + + XCTAssertEqual(items.first?.fileExtension, "m4b", "nested metadata decodes; leading dot is stripped") + XCTAssertEqual( + items.first?.duration, + 4800, + "the virtual import refuses an item without a length, so the batch payload has to carry one" + ) + XCTAssertEqual( + items.first?.chapters.map(\.title), + ["Chapter One", "Chapter Two"], + "batch/get returns EXPANDED media, so chapters arrive on the same response as audioFiles" + ) + XCTAssertEqual(items.first?.chapters.map(\.duration), [600, 4200]) + } +} + +// MARK: - External-resource host resolution + +/// KeychainServiceProtocol has no generated mock (generic requirements); a local +/// dictionary-backed stub is enough for the resolver's read path. +private final class KeychainStub: KeychainServiceProtocol, @unchecked Sendable { + let valueUpdatedPublisher = PassthroughSubject() + var storage: [KeychainKeys: Any] = [:] + + func set(_ value: String, key: KeychainKeys) throws { storage[key] = value } + func set(_ value: T, key: KeychainKeys) throws { storage[key] = value } + func get(_ key: KeychainKeys) throws -> String? { storage[key] as? String } + func get(_ key: KeychainKeys) throws -> T? { storage[key] as? T } + func remove(_ key: KeychainKeys) throws { storage[key] = nil } +} + +final class ExternalResourceResolutionTests: XCTestCase { + private func makeResource(provider: String, id: String, hostId: String?) -> SimpleExternalResource { + SimpleExternalResource( + providerName: provider, + providerId: id, + syncStatus: ExternalResource.SyncStatus.stream.rawValue, + lastSyncedAt: nil, + hostId: hostId, + libraryItem: nil + ) + } + + /// The pure resolver behind the details section: a hostId matching a saved + /// connection (GUID, case-insensitive per IntegrationHostResolver) renders the + /// server URL; an unknown host falls back to the raw hostId. + func testResolvesSavedConnectionsAndFallsBackToRawHostId() throws { + let keychain = KeychainStub() + try keychain.set( + [JellyfinConnectionData( + serverId: "GUID-JELLY", + url: URL(string: "https://jelly.example.com")!, + serverName: "Jelly", + userID: "u1", + userName: "user", + accessToken: "t" + )], + key: .jellyfinConnection + ) + + let resolved = IntegrationHostResolver.hostDisplayStrings( + for: [ + makeResource(provider: "jellyfin", id: "r1", hostId: "guid-jelly"), + makeResource(provider: "jellyfin", id: "r2", hostId: "unknown-guid"), + ], + keychain: keychain + ) + + XCTAssertEqual(resolved["r1"], "https://jelly.example.com", "GUID match is case-insensitive") + XCTAssertEqual(resolved["r2"], "unknown-guid", "unknown host falls back to the raw hostId") + } + + /// Pins the `.audiobookshelf` arm, which nothing else does. + /// `testResolvesSavedConnectionsAndFallsBackToRawHostId` above catches a wrong `.jellyfin` + /// mapping — it drives a Jellyfin resource through the host resolver, which now routes on + /// `mediaServer` — but no test drives an AudiobookShelf resource through one. So + /// `case .audiobookshelf: .jellyfin` compiles, leaves `mediaServerRawValues` untouched + /// (it filters on nil-ness, not identity, so the SQL predicate still fetches the rows) and + /// passes the whole suite, while every AudiobookShelf item resolves against the Jellyfin + /// connection: nothing to stream, its progress pushes misrouted into the Jellyfin path and + /// silently dropped by that path's own host guard, and its row wearing the wrong glyph. + func testProviderNameMapsOntoItsOwnMediaServer() { + XCTAssertEqual(ExternalResource.ProviderName.jellyfin.mediaServer, .jellyfin) + XCTAssertEqual(ExternalResource.ProviderName.audiobookshelf.mediaServer, .audiobookshelf) + XCTAssertNil(ExternalResource.ProviderName.hardcover.mediaServer) + XCTAssertEqual( + Set(ExternalResource.ProviderName.mediaServerRawValues), + ["jellyfin", "audiobookshelf"], + "the SQL IN predicate reads these strings — they are the stored providerName values" + ) + } + + /// The section shows media-server links only: Hardcover has its own section, and + /// repeating its link here would render a bare provider/id pair with no host. The rule + /// allowlists known media servers, and its switch is exhaustive, so adding a provider + /// is a compile error at the one place the question is answered. + func testMediaServerResourcesKeepsOnlyKnownMediaServers() { + let hosted = [ + makeResource(provider: "jellyfin", id: "r1", hostId: "guid-jelly"), + makeResource(provider: "hardcover", id: "12345", hostId: nil), + makeResource(provider: "audiobookshelf", id: "r2", hostId: "https://abs.example.com"), + makeResource(provider: "somethingnew", id: "r3", hostId: "guid-new"), + ].mediaServerResources + + XCTAssertEqual( + hosted.map(\.providerId), + ["r1", "r2"], + "hardcover and unknown providers are both dropped — only known media servers stream" + ) + } + + /// The streaming pick must ignore Hardcover links. They share the item's resource + /// relationship and "hardcover" sorts between "audiobookshelf" and "jellyfin", so a + /// hardcover row whose syncStatus came back from sync as anything but not_synced would + /// otherwise win the pick and leave a streamable item with no external URL. + func testStreamingResourceIgnoresHardcoverEvenWhenItSortsFirst() { + let picked = [ + makeResource(provider: "hardcover", id: "12345", hostId: nil), + makeResource(provider: "jellyfin", id: "r1", hostId: "guid-jelly"), + ].streamingResource + + XCTAssertEqual(picked?.providerName, "jellyfin") + XCTAssertEqual(picked?.providerId, "r1") + } + + /// Two streaming providers on one item resolve to a stable pick across launches, since + /// the underlying snapshot set is unordered. + func testStreamingResourceIsDeterministicAcrossProviders() { + let jellyfin = makeResource(provider: "jellyfin", id: "r1", hostId: "guid-jelly") + let abs = makeResource(provider: "audiobookshelf", id: "r2", hostId: "guid-abs") + + XCTAssertEqual( + [jellyfin, abs].streamingResource?.providerId, + [abs, jellyfin].streamingResource?.providerId, + "the pick must not depend on the order the set yields" + ) + } + + func testStreamingResourceSkipsNotSyncedAndEmptyInput() { + let notSynced = SimpleExternalResource( + providerName: "jellyfin", + providerId: "r1", + syncStatus: ExternalResource.SyncStatus.notSynced.rawValue, + lastSyncedAt: nil, + hostId: "guid-jelly", + libraryItem: nil + ) + + XCTAssertNil([notSynced].streamingResource) + XCTAssertNil([SimpleExternalResource]().streamingResource) + } + + /// A Hardcover-only item must render no external-resources section at all. + func testMediaServerResourcesIsEmptyForHardcoverOnly() { + XCTAssertTrue( + [makeResource(provider: "hardcover", id: "12345", hostId: nil)].mediaServerResources.isEmpty + ) + XCTAssertTrue([SimpleExternalResource]().mediaServerResources.isEmpty) + } +} + +/// The item-side half of the "Media Servers" button rule on a playback-failure alert. +/// The entitlement half is exercised against a real PlayerManager in PlayerManagerTests. +final class MediaServersShortcutTests: XCTestCase { + private func makeChapter(externalUrl: URL?, hasUnresolvedExternalHost: Bool) -> PlayableChapter { + PlayableChapter( + title: "Chapter", + author: "Author", + start: 0, + duration: 100, + relativePath: "missing-book.m4b", + remoteURL: URL(string: "https://s3.example.com/presigned"), + externalURL: externalUrl, + index: 1, + hasUnresolvedExternalHost: hasUnresolvedExternalHost + ) + } + + /// The case the old condition got wrong: on a device where the item's media server was + /// never added, nothing resolves the host, so there is no external URL — but the API + /// still hands back a presigned remoteURL, which is what used to suppress the button. + func testNeedsMediaServerWhenTheServerIsNotConfiguredOnThisDevice() { + XCTAssertTrue( + makeChapter(externalUrl: nil, hasUnresolvedExternalHost: true).needsMediaServer() + ) + } + + func testNeedsMediaServerWhenTheStreamItselfFailed() { + XCTAssertTrue( + makeChapter( + externalUrl: URL(string: "https://jelly.example.com/stream"), + hasUnresolvedExternalHost: false + ).needsMediaServer() + ) + } + + /// A plain local file that went missing has nothing to do with a media server. + func testDoesNotNeedMediaServerWithoutAMediaServerResource() { + XCTAssertFalse( + makeChapter(externalUrl: nil, hasUnresolvedExternalHost: false).needsMediaServer() + ) + } + + /// The file is on disk, so whatever failed, a missing server isn't it. + func testDoesNotNeedMediaServerWhenTheFileExists() throws { + let folder = DataManager.getProcessedFolderURL() + let onDisk = folder.appendingPathComponent("present-book.m4b") + try Data("audio".utf8).write(to: onDisk) + defer { try? FileManager.default.removeItem(at: onDisk) } + + let chapter = PlayableChapter( + title: "Chapter", + author: "Author", + start: 0, + duration: 100, + relativePath: "present-book.m4b", + remoteURL: nil, + externalURL: nil, + index: 1, + hasUnresolvedExternalHost: true + ) + + XCTAssertFalse(chapter.needsMediaServer()) + } +} + +/// Construction-time work moved out of `ItemDetailsViewModel.init` into `load()`, which is +/// what makes the view model constructible in a test at all: `init` no longer starts an +/// unstructured task that reaches the network. +@MainActor +final class ItemDetailsLoadTests: XCTestCase { + /// HardcoverServiceProtocol isn't AutoMockable, and its seven members are few enough to + /// stub by hand rather than pull Sourcery into the change. + private final class HardcoverServiceStub: HardcoverServiceProtocol { + var authorization: String? + var getBookCallCount = 0 + var bookToReturn: SimpleHardcoverBook? + + func getBook(id: Int) async throws -> SimpleHardcoverBook? { + getBookCallCount += 1 + return bookToReturn + } + + func getBooks(for item: SimpleLibraryItem, perPage: Int) async throws -> BooksData { + throw BookPlayerError.runtimeError("not used") + } + func searchBooks(query: String, perPage: Int) async throws -> BooksData { + throw BookPlayerError.runtimeError("not used") + } + func processAutoMatch(for items: [SimpleLibraryItem]) async {} + func assignItem(_ book: SimpleHardcoverBook?, to item: SimpleLibraryItem) async {} + func removeFromLibrary(_ book: SimpleHardcoverBook) async throws {} + } + + private func makeSUT() -> (ItemDetailsViewModel, LibraryServiceProtocolMock, HardcoverServiceStub) { + let hardcoverResource = SimpleExternalResource( + providerName: ExternalResource.ProviderName.hardcover.rawValue, + providerId: "12345", + syncStatus: ExternalResource.SyncStatus.stream.rawValue, + lastSyncedAt: nil, + libraryItem: nil + ) + let item = SimpleLibraryItem( + title: "Local Title", + details: "Local Author", + speed: 1, + currentTime: 0, + duration: 100, + percentCompleted: 0, + isFinished: false, + relativePath: "book.m4b", + remoteURL: nil, + artworkURL: nil, + orderRank: 0, + parentFolder: nil, + originalFileName: "book.m4b", + lastPlayDate: nil, + type: .book, + uuid: "UUID", + externalResources: [hardcoverResource] + ) + + let libraryService = LibraryServiceProtocolMock() + // No local Hardcover row on this device: the synced-down link is all we have, which is + // the path that fetches. + libraryService.getHardcoverBookForReturnValue = nil + libraryService.getExternalResourcesForReturnValue = [hardcoverResource] + + let hardcoverService = HardcoverServiceStub() + // ItemDetailsHardcoverSectionViewModel.init? returns nil without a token, and the whole + // hardcover resolve path is gated on that section existing — so an unconnected account + // resolves nothing, by design. + hardcoverService.authorization = "test-token" + hardcoverService.bookToReturn = SimpleHardcoverBook( + id: 12345, + artworkURL: nil, + title: "Fetched Title", + author: "Fetched Author", + status: .reading + ) + + let sut = ItemDetailsViewModel( + item: item, + libraryService: libraryService, + syncService: SyncServiceProtocolMock(), + hardcoverService: hardcoverService, + listState: ListStateManager() + ) + + return (sut, libraryService, hardcoverService) + } + + /// The regression this guards: `.task` fires again whenever the view re-appears, and the + /// load must not refetch (or re-clobber the picker selection) when it does. + func testLoadRunsOnlyOnceAcrossRepeatedCalls() async { + let (sut, libraryService, hardcoverService) = makeSUT() + + await sut.load() + await sut.load() + await sut.load() + + XCTAssertEqual(hardcoverService.getBookCallCount, 1, "a re-appear must not refetch") + XCTAssertEqual(libraryService.getHardcoverBookForCallsCount, 1) + } + + /// Behavior that had no coverage before, because the view model couldn't be constructed: + /// with no local row, the picker is seeded from the synced resource and then upgraded to + /// the metadata Hardcover returns. + func testLoadResolvesTheSelectionFromASyncedResource() async { + let (sut, _, _) = makeSUT() + + XCTAssertNil(sut.hardcoverSectionViewModel?.pickerViewModel.selected) + + await sut.load() + + let selected = sut.hardcoverSectionViewModel?.pickerViewModel.selected + XCTAssertEqual(selected?.id, 12345) + XCTAssertEqual(selected?.title, "Fetched Title", "the interim title upgrades to the fetch") + XCTAssertEqual(sut.hardcoverSectionViewModel?.isFetchingBook, false, "spinner is cleared") + } + + /// Nothing is fetched off the main path during construction any more. + func testInitDoesNotResolveAnything() { + let (sut, libraryService, hardcoverService) = makeSUT() + + XCTAssertEqual(hardcoverService.getBookCallCount, 0) + XCTAssertEqual(libraryService.getHardcoverBookForCallsCount, 0) + XCTAssertTrue(sut.resolvedExternalHosts.isEmpty) + } +} + +// MARK: - External progress pull + +/// The inbound half of media-server progress sync, extracted out of ItemListViewModel. +/// +/// Waits are expectations fulfilled by the publisher, never fixed sleeps: the service's timing +/// is what's under test, and a fixed wait either flakes on a slow runner or hides a real +/// delay. Notifications go through a private center so nothing here can reach a real +/// service alive elsewhere in the test process. +@MainActor +final class ExternalProgressServiceTests: XCTestCase { + /// Records what it was asked and answers from a script, so a test can assert the fan-out + /// without a server, a keychain, or a network. A slow answer sleeps WITHOUT swallowing + /// cancellation, so a cancelled refresh provably reaches the provider. + private final class ProviderStub: ExternalProgressProviding, @unchecked Sendable { + private let lock = NSLock() + private var _requested: [String] = [] + private var _chapterRequests: [[String]] = [] + private let answer: @Sendable (SimpleExternalResource) async throws -> ExternalPlaybackProgress? + /// Attached to every answered snapshot, so a test can script the chapter half without + /// restating the position half. + private let chapters: [ChapterMetadata] + + var requested: [String] { + lock.lock() + defer { lock.unlock() } + return _requested + } + + /// The id sets asked WITH chapters, one entry per call — so a test can assert both that + /// chapters were requested for the right items and that they weren't requested at all. + var chapterRequests: [[String]] { + lock.lock() + defer { lock.unlock() } + return _chapterRequests + } + + init( + chapters: [ChapterMetadata] = [], + answer: @escaping @Sendable (SimpleExternalResource) async throws -> ExternalPlaybackProgress? + ) { + self.chapters = chapters + self.answer = answer + } + + func progress(for resource: SimpleExternalResource) async throws -> ExternalPlaybackProgress? { + lock.lock() + _requested.append(resource.providerId) + lock.unlock() + return try await answer(resource) + } + + func progress( + forBatch resources: [SimpleExternalResource], + includingChapters: Bool + ) async throws -> [String: ExternalItemSnapshot] { + if includingChapters { + lock.lock() + _chapterRequests.append(resources.map(\.providerId)) + lock.unlock() + } + + var out: [String: ExternalItemSnapshot] = [:] + for resource in resources { + if let progress = try await progress(for: resource) { + out[resource.providerId] = ExternalItemSnapshot( + progress: progress, + chapters: includingChapters ? chapters : [] + ) + } + } + return out + } + } + + private var notificationCenter: NotificationCenter! + private var accountService: AccountServiceMock! + private var received: [ExternalPlaybackProgress] = [] + private var cancellable: AnyCancellable? + + override func setUp() { + super.setUp() + notificationCenter = NotificationCenter() + accountService = AccountServiceMock(account: nil) + received = [] + } + + override func tearDown() { + cancellable?.cancel() + cancellable = nil + super.tearDown() + } + + private func makeResource( + provider: String, + id: String, + needsChapters: Bool = false + ) -> SimpleExternalResource { + SimpleExternalResource( + providerName: provider, + providerId: id, + syncStatus: ExternalResource.SyncStatus.stream.rawValue, + lastSyncedAt: nil, + hostId: "guid-host", + libraryItem: nil, + needsChapters: needsChapters + ) + } + + private func makeItem(uuid: String, currentTime: TimeInterval, lastPlayDate: Date?) -> PlayableItem { + PlayableItem( + title: "Book", + author: "Author", + chapters: [ + PlayableChapter( + title: "Chapter", + author: "Author", + start: 0, + duration: 1000, + relativePath: "book.m4b", + remoteURL: nil, + externalURL: nil, + index: 1 + ) + ], + currentTime: currentTime, + duration: 1000, + relativePath: "book.m4b", + uuid: uuid, + parentFolder: nil, + percentCompleted: 0, + lastPlayDate: lastPlayDate, + isFinished: false, + isBoundBook: false + ) + } + + /// Entitled (lite/pro) unless a test says otherwise — the pull is gated, the push is not. + private func makeSUT( + resources: [SimpleExternalResource], + providers: [ExternalResource.ProviderName: ExternalProgressProviding], + syncEnabled: Bool = true + ) -> (ExternalProgressService, LibraryServiceProtocolMock) { + let libraryService = LibraryServiceProtocolMock() + libraryService.findResourcesForReturnValue = resources + accountService.hasSyncEnabledValue = syncEnabled + + let sut = ExternalProgressService() + sut.setup( + libraryService: libraryService, + accountService: accountService, + providers: providers, + notificationCenter: notificationCenter + ) + return (sut, libraryService) + } + + /// Subscribes and returns an expectation that fulfils once per published position. + private func expectPublish(from sut: ExternalProgressService, count: Int = 1, inverted: Bool = false) -> XCTestExpectation { + let expectation = expectation(description: inverted ? "nothing published" : "position published") + expectation.isInverted = inverted + if !inverted { + expectation.expectedFulfillmentCount = count + expectation.assertForOverFulfill = true + } + cancellable = sut.promptablePositionPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] position in + self?.received.append(position) + expectation.fulfill() + } + return expectation + } + + private func slowStub(_ progress: ExternalPlaybackProgress) -> ProviderStub { + ProviderStub { _ in + try await Task.sleep(nanoseconds: 250_000_000) + return progress + } + } + + // MARK: the decision rule + + func testPromptsWhenTheRemoteDateIsNewerBeyondTheThreshold() { + let position = [ExternalPlaybackProgress(currentTime: 90, lastPlayedDate: Date(timeIntervalSince1970: 1100))] + .promptable(localTime: 100, localDate: Date(timeIntervalSince1970: 1000)) + + XCTAssertEqual(position?.currentTime, 90, "a newer date prompts even when the position is behind") + } + + func testDoesNotPromptInsideTheThreshold() { + let position = [ExternalPlaybackProgress(currentTime: 110, lastPlayedDate: Date(timeIntervalSince1970: 1005))] + .promptable(localTime: 100, localDate: Date(timeIntervalSince1970: 1000)) + + XCTAssertNil(position, "10s of drift on the book you are listening to is not another device") + } + + func testPromptsWhenTheRemotePositionIsFartherWithoutADate() { + let position = [ExternalPlaybackProgress(currentTime: 400, lastPlayedDate: nil)] + .promptable(localTime: 100, localDate: Date(timeIntervalSince1970: 1000)) + + XCTAssertEqual(position?.currentTime, 400, "a server reporting no date still counts on position") + } + + /// The rule SyncService.handleSyncedLastPlayed uses for our own cloud: newest date wins. + func testNewestCandidateWinsAcrossServers() { + let older = ExternalPlaybackProgress(currentTime: 900, lastPlayedDate: Date(timeIntervalSince1970: 1100)) + let newer = ExternalPlaybackProgress(currentTime: 300, lastPlayedDate: Date(timeIntervalSince1970: 2000)) + + let position = [older, newer] + .promptable(localTime: 100, localDate: Date(timeIntervalSince1970: 1000)) + + XCTAssertEqual(position, newer, "the most recently played server wins, not the farthest position") + } + + func testNoCandidatesMeansNoPrompt() { + XCTAssertNil( + [ExternalPlaybackProgress]().promptable(localTime: 100, localDate: nil) + ) + } + + // MARK: the service + + func testAsksEveryLinkedServerConcurrentlyAndPublishesTheNewest() async { + let jellyfin = ProviderStub { _ in + ExternalPlaybackProgress(currentTime: 800, lastPlayedDate: Date(timeIntervalSince1970: 1100)) + } + let abs = ProviderStub { _ in + ExternalPlaybackProgress(currentTime: 200, lastPlayedDate: Date(timeIntervalSince1970: 5000)) + } + let (sut, _) = makeSUT( + resources: [makeResource(provider: "jellyfin", id: "jf-1"), makeResource(provider: "audiobookshelf", id: "abs-1")], + providers: [.jellyfin: jellyfin, .audiobookshelf: abs] + ) + let published = expectPublish(from: sut) + + sut.refreshProgress(for: makeItem(uuid: "UUID", currentTime: 100, lastPlayDate: Date(timeIntervalSince1970: 1000))) + await fulfillment(of: [published], timeout: 2) + + XCTAssertEqual(jellyfin.requested, ["jf-1"], "both servers are asked") + XCTAssertEqual(abs.requested, ["abs-1"]) + XCTAssertEqual(received.map(\.currentTime), [200], "the newest date wins across providers") + } + + func testOneFailingServerDoesNotSilenceTheOther() async { + let failing = ProviderStub { _ in throw URLError(.timedOut) } + let working = ProviderStub { _ in + ExternalPlaybackProgress(currentTime: 700, lastPlayedDate: Date(timeIntervalSince1970: 9000)) + } + let (sut, _) = makeSUT( + resources: [makeResource(provider: "jellyfin", id: "jf-1"), makeResource(provider: "audiobookshelf", id: "abs-1")], + providers: [.jellyfin: failing, .audiobookshelf: working] + ) + let published = expectPublish(from: sut) + + sut.refreshProgress(for: makeItem(uuid: "UUID", currentTime: 0, lastPlayDate: nil)) + await fulfillment(of: [published], timeout: 2) + + XCTAssertEqual(received.map(\.currentTime), [700], "a thrown error takes out only its own provider") + } + + /// Hardcover shares the resource relationship but hosts nothing, so it must never be asked. + func testSkipsResourcesWithNoMediaServerProvider() async { + let jellyfin = ProviderStub { _ in XCTFail("hardcover must not reach a provider"); return nil } + let (sut, _) = makeSUT( + resources: [makeResource(provider: "hardcover", id: "12345")], + providers: [.jellyfin: jellyfin] + ) + let nothing = expectPublish(from: sut, inverted: true) + + sut.refreshProgress(for: makeItem(uuid: "UUID", currentTime: 0, lastPlayDate: nil)) + await fulfillment(of: [nothing], timeout: 0.3) + + XCTAssertTrue(jellyfin.requested.isEmpty) + } + + /// The bug that started the extraction: a slow answer for a book the user already left + /// must not raise a prompt carrying that book's position. With cancellation propagating + /// into the stub, the superseded refresh never even completes. + func testAnswerForASupersededItemIsDiscarded() async { + let slow = slowStub(ExternalPlaybackProgress(currentTime: 900, lastPlayedDate: Date(timeIntervalSince1970: 9000))) + let (sut, _) = makeSUT( + resources: [makeResource(provider: "jellyfin", id: "jf-1")], + providers: [.jellyfin: slow] + ) + let published = expectPublish(from: sut, count: 1) + + sut.refreshProgress(for: makeItem(uuid: "FIRST", currentTime: 0, lastPlayDate: nil)) + // A different book starts before the first answer lands. + sut.refreshProgress(for: makeItem(uuid: "SECOND", currentTime: 0, lastPlayDate: nil)) + await fulfillment(of: [published], timeout: 2) + + XCTAssertEqual(received.count, 1, "only the item that is playing now can prompt") + XCTAssertEqual(slow.requested, ["jf-1", "jf-1"], "both refreshes reached the provider; only one survived") + } + + /// The point of the extraction: the pull is driven by the notification the player posts for + /// EVERY playback start, so it no longer depends on which UI holds a delegate slot. + func testRefreshesFromTheBookPlayedNotification() async { + let jellyfin = ProviderStub { _ in + ExternalPlaybackProgress(currentTime: 600, lastPlayedDate: Date(timeIntervalSince1970: 9000)) + } + let (sut, _) = makeSUT( + resources: [makeResource(provider: "jellyfin", id: "jf-1")], + providers: [.jellyfin: jellyfin] + ) + let published = expectPublish(from: sut) + + notificationCenter.post( + name: .bookPlayed, + object: nil, + userInfo: ["book": makeItem(uuid: "UUID", currentTime: 0, lastPlayDate: nil)] + ) + await fulfillment(of: [published], timeout: 2) + + XCTAssertEqual(jellyfin.requested, ["jf-1"], "playback start alone drives the pull") + XCTAssertEqual(received.map(\.currentTime), [600]) + } + + func testLogoutNotificationCancelsInFlightWork() async { + let slow = slowStub(ExternalPlaybackProgress(currentTime: 900, lastPlayedDate: Date(timeIntervalSince1970: 9000))) + let (sut, _) = makeSUT( + resources: [makeResource(provider: "jellyfin", id: "jf-1")], + providers: [.jellyfin: slow] + ) + let nothing = expectPublish(from: sut, inverted: true) + + sut.refreshProgress(for: makeItem(uuid: "UUID", currentTime: 0, lastPlayDate: nil)) + notificationCenter.post(name: .logout, object: nil) + // Longer than the stub's sleep, so an un-cancelled refresh WOULD have published by now. + await fulfillment(of: [nothing], timeout: 0.6) + } + + func testTeardownCancelsAnInFlightRefresh() async { + let slow = slowStub(ExternalPlaybackProgress(currentTime: 900, lastPlayedDate: Date(timeIntervalSince1970: 9000))) + let (sut, _) = makeSUT( + resources: [makeResource(provider: "jellyfin", id: "jf-1")], + providers: [.jellyfin: slow] + ) + let nothing = expectPublish(from: sut, inverted: true) + + sut.refreshProgress(for: makeItem(uuid: "UUID", currentTime: 0, lastPlayDate: nil)) + sut.teardown() + await fulfillment(of: [nothing], timeout: 0.6) + } + + // MARK: the list refresh + + /// The gap this closes: the list refresh collected Jellyfin resources only and handed them + /// to an ingest typed to a Jellyfin item, so AudiobookShelf items were never refreshed. + /// The level's links come from ONE resource-first library query, asked for exactly the path + /// the list is syncing. + func testRefreshItemsAtLevelFoldsInBothProviders() async { + let jellyfin = ProviderStub { _ in + ExternalPlaybackProgress(currentTime: 120, lastPlayedDate: Date(timeIntervalSince1970: 500)) + } + let abs = ProviderStub { _ in + ExternalPlaybackProgress(currentTime: 340, lastPlayedDate: Date(timeIntervalSince1970: 900)) + } + let (sut, libraryService) = makeSUT(resources: [], providers: [.jellyfin: jellyfin, .audiobookshelf: abs]) + libraryService.findMediaServerResourcesAtReturnValue = [ + makeResource(provider: "jellyfin", id: "jf-1"), + makeResource(provider: "audiobookshelf", id: "abs-1"), + ] + + await sut.refreshItems(at: "Author/Series") + + XCTAssertEqual(libraryService.findMediaServerResourcesAtReceivedInvocations, ["Author/Series"]) + XCTAssertEqual(jellyfin.requested, ["jf-1"]) + XCTAssertEqual(abs.requested, ["abs-1"], "AudiobookShelf items refresh too") + + let ingested = libraryService.handleSyncFromExternalResourceProviderNameSnapshotsByProviderIdReceivedInvocations + XCTAssertEqual( + Set(ingested.map(\.providerName)), + ["jellyfin", "audiobookshelf"], + "each provider folds its own answers in, under its own provider name" + ) + } + + /// The second-device path: a row arrives through BookPlayer's own sync, which carries no + /// chapters, and the level refresh is the only thing that can supply them. + func testRefreshItemsForwardsTheProvidersChaptersToTheIngest() async { + let chapters = [ + ChapterMetadata(title: "One", start: 0, duration: 600, index: 1), + ChapterMetadata(title: "Two", start: 600, duration: 900, index: 2), + ] + let jellyfin = ProviderStub(chapters: chapters) { _ in + ExternalPlaybackProgress(currentTime: 120, lastPlayedDate: Date(timeIntervalSince1970: 500)) + } + let (sut, libraryService) = makeSUT(resources: [], providers: [.jellyfin: jellyfin]) + // The LEVEL path reads this one; `resources:` above feeds the on-play path. + libraryService.findMediaServerResourcesAtReturnValue = [ + makeResource(provider: "jellyfin", id: "jf-1", needsChapters: true) + ] + + await sut.refreshItems(at: nil) + + let ingested = libraryService.handleSyncFromExternalResourceProviderNameSnapshotsByProviderIdReceivedInvocations + XCTAssertEqual( + ingested.first?.snapshotsByProviderId["jf-1"]?.chapters, + chapters, + "chapters ride the same snapshot as the position, unchanged" + ) + } + + /// The repeated case, and the reason the flag exists: once every book has chapters the + /// refresh must cost exactly what a progress-only refresh always cost. + func testRefreshItemsAsksForNoChaptersWhenNothingNeedsThem() async { + let jellyfin = ProviderStub { _ in + ExternalPlaybackProgress(currentTime: 120, lastPlayedDate: Date(timeIntervalSince1970: 500)) + } + let (sut, libraryService) = makeSUT(resources: [], providers: [.jellyfin: jellyfin]) + libraryService.findMediaServerResourcesAtReturnValue = [ + makeResource(provider: "jellyfin", id: "jf-1"), + makeResource(provider: "jellyfin", id: "jf-2"), + ] + + await sut.refreshItems(at: nil) + + XCTAssertTrue(jellyfin.chapterRequests.isEmpty, "a chapters request here would repeat forever") + XCTAssertEqual(Set(jellyfin.requested), ["jf-1", "jf-2"], "every item is still refreshed for position") + } + + /// A book whose server has no chapters never gets filled, so it stays in the needing set — + /// it must not drag the whole level's chapters along on every refresh. + func testRefreshItemsAsksForChaptersOnlyForTheItemsMissingThem() async { + let jellyfin = ProviderStub { _ in + ExternalPlaybackProgress(currentTime: 120, lastPlayedDate: Date(timeIntervalSince1970: 500)) + } + let (sut, libraryService) = makeSUT(resources: [], providers: [.jellyfin: jellyfin]) + libraryService.findMediaServerResourcesAtReturnValue = [ + makeResource(provider: "jellyfin", id: "has-chapters"), + makeResource(provider: "jellyfin", id: "needs-chapters", needsChapters: true), + ] + + await sut.refreshItems(at: nil) + + XCTAssertEqual(jellyfin.chapterRequests, [["needs-chapters"]]) + XCTAssertEqual( + Set(jellyfin.requested), + ["has-chapters", "needs-chapters"], + "both partitions are still asked for position, and both reach the ingest" + ) + + let ingested = libraryService.handleSyncFromExternalResourceProviderNameSnapshotsByProviderIdReceivedInvocations + XCTAssertEqual( + Set(ingested.first?.snapshotsByProviderId.keys ?? [:].keys), + ["has-chapters", "needs-chapters"], + "the two partitions' answers are merged before the ingest" + ) + } + + func testRefreshItemsAtLevelDoesNothingWithoutMediaServerResources() async { + let jellyfin = ProviderStub { _ in XCTFail("must not be asked"); return nil } + let (sut, libraryService) = makeSUT(resources: [], providers: [.jellyfin: jellyfin]) + libraryService.findMediaServerResourcesAtReturnValue = [] + + await sut.refreshItems(at: nil) + + XCTAssertEqual(libraryService.findMediaServerResourcesAtReceivedInvocations, [nil], "root is asked as nil") + XCTAssertTrue(jellyfin.requested.isEmpty) + XCTAssertEqual(libraryService.handleSyncFromExternalResourceProviderNameSnapshotsByProviderIdCallsCount, 0) + } + + // MARK: the entitlement gate (pull only — the push runs on every tier) + + func testOnPlayPullIsGatedToSyncTiers() async { + let jellyfin = ProviderStub { _ in XCTFail("a free account must not pull"); return nil } + let (sut, _) = makeSUT( + resources: [makeResource(provider: "jellyfin", id: "jf-1")], + providers: [.jellyfin: jellyfin], + syncEnabled: false + ) + let nothing = expectPublish(from: sut, inverted: true) + + sut.refreshProgress(for: makeItem(uuid: "UUID", currentTime: 0, lastPlayDate: nil)) + await fulfillment(of: [nothing], timeout: 0.3) + + XCTAssertTrue(jellyfin.requested.isEmpty) + } + + /// Gated before the library is even queried: a free account costs no fetch at all. + func testListPullIsGatedToSyncTiers() async { + let jellyfin = ProviderStub { _ in XCTFail("a free account must not pull"); return nil } + let (sut, libraryService) = makeSUT(resources: [], providers: [.jellyfin: jellyfin], syncEnabled: false) + libraryService.findMediaServerResourcesAtReturnValue = [makeResource(provider: "jellyfin", id: "jf-1")] + + await sut.refreshItems(at: "Folder") + + XCTAssertEqual(libraryService.findMediaServerResourcesAtCallsCount, 0) + XCTAssertTrue(jellyfin.requested.isEmpty) + XCTAssertEqual(libraryService.handleSyncFromExternalResourceProviderNameSnapshotsByProviderIdCallsCount, 0) + } + + /// The entitlement is read live, so a downgrade takes effect on the NEXT pull; the one + /// already in flight is cancelled by the account-update observer. + func testDowngradeCancelsAnInFlightPull() async { + let slow = slowStub(ExternalPlaybackProgress(currentTime: 900, lastPlayedDate: Date(timeIntervalSince1970: 9000))) + let (sut, _) = makeSUT( + resources: [makeResource(provider: "jellyfin", id: "jf-1")], + providers: [.jellyfin: slow] + ) + let nothing = expectPublish(from: sut, inverted: true) + + sut.refreshProgress(for: makeItem(uuid: "UUID", currentTime: 0, lastPlayDate: nil)) + accountService.hasSyncEnabledValue = false + notificationCenter.post(name: .accountUpdate, object: nil) + // Longer than the stub's sleep, so an un-cancelled refresh WOULD have published by now. + await fulfillment(of: [nothing], timeout: 0.6) + + XCTAssertEqual(slow.requested, ["jf-1"], "the pull had started; the downgrade stopped it") + } + + /// Account updates that keep the entitlement (renewal, pro↔lite) must not cancel anything. + func testAccountUpdateWhileEntitledKeepsThePull() async { + let slow = slowStub(ExternalPlaybackProgress(currentTime: 900, lastPlayedDate: Date(timeIntervalSince1970: 9000))) + let (sut, _) = makeSUT( + resources: [makeResource(provider: "jellyfin", id: "jf-1")], + providers: [.jellyfin: slow] + ) + let published = expectPublish(from: sut) + + sut.refreshProgress(for: makeItem(uuid: "UUID", currentTime: 0, lastPlayDate: nil)) + notificationCenter.post(name: .accountUpdate, object: nil) + await fulfillment(of: [published], timeout: 2) + + XCTAssertEqual(received.map(\.currentTime), [900]) + } +} + +// MARK: - External stream resolution + +/// Stream URLs, auth headers and the unresolved-host flag. None of this could be tested while +/// the logic sat inside PlaybackService.getPlayableChapters with its own KeychainService(). +final class ExternalStreamResolverTests: XCTestCase { + private func makeResource(provider: String, id: String, hostId: String?) -> SimpleExternalResource { + SimpleExternalResource( + providerName: provider, + providerId: id, + syncStatus: ExternalResource.SyncStatus.stream.rawValue, + lastSyncedAt: nil, + hostId: hostId, + libraryItem: nil + ) + } + + private func makeKeychain(customHeaders: [String: String] = [:]) throws -> KeychainStub { + let keychain = KeychainStub() + try keychain.set( + [ + JellyfinConnectionData( + serverId: "GUID-JELLY", + url: URL(string: "https://jelly.example.com")!, + serverName: "Jelly", + userID: "u1", + userName: "user", + accessToken: "jelly-token", + customHeaders: customHeaders + ) + ], + key: .jellyfinConnection + ) + return keychain + } + + func testResolvesTheResourcesOwnServerWithItsAuthHeader() throws { + let sut = ExternalStreamResolver(keychain: try makeKeychain()) + + let source = sut.streamSource(for: makeResource(provider: "jellyfin", id: "item-1", hostId: "guid-jelly")) + + XCTAssertEqual(source?.url.host, "jelly.example.com") + XCTAssertEqual( + source?.headers["Authorization"], + "MediaBrowser Token=\"jelly-token\"", + "the integration's own token authorises the stream, never the BookPlayer JWT" + ) + } + + /// The contract shared with the Android app: an unmatched host resolves to nothing rather + /// than streaming from whichever server happens to be configured. + func testDoesNotFallBackToAnotherServer() throws { + let sut = ExternalStreamResolver(keychain: try makeKeychain()) + + XCTAssertNil( + sut.streamSource(for: makeResource(provider: "jellyfin", id: "item-1", hostId: "guid-somewhere-else")) + ) + } + + /// Custom headers exist for reverse-proxy gates; the integration's Authorization must win, + /// and a lowercase user-configured key must not fight it. + func testIntegrationAuthorizationWinsOverACustomHeader() throws { + let keychain = try makeKeychain(customHeaders: [ + "CF-Access-Client-Id": "cf-id", + "authorization": "Bearer user-configured", + ]) + let sut = ExternalStreamResolver(keychain: keychain) + + let source = sut.streamSource(for: makeResource(provider: "jellyfin", id: "item-1", hostId: "guid-jelly")) + + XCTAssertEqual(source?.headers["CF-Access-Client-Id"], "cf-id", "proxy gates survive") + XCTAssertEqual(source?.headers["Authorization"], "MediaBrowser Token=\"jelly-token\"") + XCTAssertEqual( + source?.headers.filter { $0.key.caseInsensitiveCompare("Authorization") == .orderedSame }.count, + 1, + "one Authorization header, not two spellings of it" + ) + } + + func testHardcoverStreamsFromNowhere() throws { + let sut = ExternalStreamResolver(keychain: try makeKeychain()) + + XCTAssertNil(sut.streamSource(for: makeResource(provider: "hardcover", id: "12345", hostId: nil))) + } +} + +/// The flag PlaybackService puts on every chapter, now reachable because resolution is injected. +final class PlayableChapterExternalHostTests: XCTestCase { + private struct ResolverStub: ExternalStreamResolving { + let source: ExternalStreamSource? + func streamSource(for resource: SimpleExternalResource) -> ExternalStreamSource? { source } + } + + private func makeItem(resources: [SimpleExternalResource]?) -> SimpleLibraryItem { + SimpleLibraryItem( + title: "Book", + details: "Author", + speed: 1, + currentTime: 0, + duration: 100, + percentCompleted: 0, + isFinished: false, + relativePath: "book.m4b", + remoteURL: nil, + artworkURL: nil, + orderRank: 0, + parentFolder: nil, + originalFileName: "book.m4b", + lastPlayDate: nil, + type: .book, + uuid: "UUID", + externalResources: resources + ) + } + + private func makeSUT(resolved: Bool) -> PlaybackService { + let libraryService = LibraryServiceProtocolMock() + libraryService.getChaptersFromReturnValue = [ + SimpleChapter(title: "Chapter", start: 0, duration: 100, index: 1) + ] + + let sut = PlaybackService() + sut.setup( + libraryService: libraryService, + streamResolver: ResolverStub( + source: resolved + ? ExternalStreamSource( + url: URL(string: "https://jelly.example.com/stream")!, + headers: ["Authorization": "MediaBrowser Token=\"t\""] + ) + : nil + ) + ) + return sut + } + + private func mediaServerResource() -> SimpleExternalResource { + SimpleExternalResource( + providerName: "jellyfin", + providerId: "item-1", + syncStatus: ExternalResource.SyncStatus.stream.rawValue, + lastSyncedAt: nil, + hostId: "guid-jelly", + libraryItem: nil + ) + } + + func testResolvedResourceCarriesTheStreamUrlAndNoUnresolvedFlag() throws { + let chapters = try makeSUT(resolved: true).getPlayableChapters(book: makeItem(resources: [mediaServerResource()])) + + XCTAssertEqual(chapters.first?.externalUrl?.host, "jelly.example.com") + XCTAssertEqual(chapters.first?.externalHeaders["Authorization"], "MediaBrowser Token=\"t\"") + XCTAssertFalse(chapters.first?.hasUnresolvedExternalHost ?? true) + } + + /// The case the Media Servers shortcut depends on: a media-server item whose host resolves + /// to nothing on this device. + func testUnresolvedHostSetsTheFlagWithNoStreamUrl() throws { + let chapters = try makeSUT(resolved: false).getPlayableChapters(book: makeItem(resources: [mediaServerResource()])) + + XCTAssertNil(chapters.first?.externalUrl) + XCTAssertTrue(chapters.first?.hasUnresolvedExternalHost ?? false) + } + + /// A plain local book has no media server, so a missing file is not a server problem. + func testItemWithoutResourcesNeverSetsTheFlag() throws { + let chapters = try makeSUT(resolved: false).getPlayableChapters(book: makeItem(resources: nil)) + + XCTAssertNil(chapters.first?.externalUrl) + XCTAssertFalse(chapters.first?.hasUnresolvedExternalHost ?? true) + } +} + +// MARK: - Provider runtime mapping + +/// The runtime is the sole source of an external row's `duration` — nothing opens the file — +/// so where the mapper reads it from decides whether an item is importable at all. +final class JellyfinRuntimeMappingTests: XCTestCase { + private let twentyMinutesInTicks = 12_000_000_000 + + func testItemLevelRuntimeWins() { + XCTAssertEqual( + JellyfinLibraryItem.resolveRuntimeSeconds(itemTicks: twentyMinutesInTicks, mediaSourceTicks: 1), + 1200 + ) + } + + func testMediaSourceRuntimeCoversAnItemReportedWithoutOne() { + XCTAssertEqual( + JellyfinLibraryItem.resolveRuntimeSeconds(itemTicks: nil, mediaSourceTicks: twentyMinutesInTicks), + 1200, + "an item Jellyfin hasn't probed still has a runtime on its media source" + ) + } + + func testNoRuntimeAnywhereStaysUnmeasured() { + XCTAssertNil( + JellyfinLibraryItem.resolveRuntimeSeconds(itemTicks: nil, mediaSourceTicks: nil), + "nil, not 0 — the mapper collapses it to 0 for `durationSeconds`, which the import gate rejects" + ) + } +} + +// MARK: - Virtual import payload + +/// The chain this phase exists to protect: the hydrated duration has to survive all the way +/// onto the import payload, because `createExternalBook` copies it straight into +/// `book.duration` and nothing ever measures the file afterwards. +@MainActor +final class VirtualImportPayloadTests: XCTestCase { + func testAudiobookShelfPayloadCarriesTheHydratedDuration() { + let item = AudiobookShelfLibraryItem( + id: "abs-1", + title: "Dune", + kind: .audiobook, + libraryId: "lib", + duration: 60, + progress: 0.25, + currentTime: 1200 + ) + + let resource = item.asVirtualImportResource( + fileExtension: "m4b", + duration: 4800, + connectionService: AudiobookShelfConnectionService(), + artworkSize: CGSize(width: 300, height: 300) + ) + + XCTAssertEqual( + resource.libraryItem?.duration, + 4800, + "the HYDRATED length wins over the minified list item's own value" + ) + XCTAssertEqual(resource.libraryItem?.percentCompleted, 25, "ABS reports progress as a 0-1 fraction") + } + + func testJellyfinPayloadCarriesTheHydratedDuration() { + let item = JellyfinLibraryItem( + id: "jf-1", + name: "Dune", + kind: .audiobook, + durationSeconds: 60, + currentSeconds: 1200, + isFinished: false, + lastPlayedDate: nil, + blurHash: nil, + imageAspectRatio: nil, + details: nil, + chapters: [] + ) + + let resource = item.asVirtualImportResource( + fileExtension: "m4b", + duration: 4800, + detailsOverride: nil, + connectionService: JellyfinConnectionService(), + artworkSize: CGSize(width: 200, height: 200) + ) + + XCTAssertEqual(resource.libraryItem?.duration, 4800) + XCTAssertEqual( + resource.libraryItem?.percentCompleted, + 25, + "progress divides by the hydrated duration, which the pipeline guarantees is non-zero" + ) + } +} + +/// The file extension decides importability alongside the runtime, and it has the same +/// two-producer hazard: the list mapper and `fetchItemDetails` must agree. +final class JellyfinFileExtensionMappingTests: XCTestCase { + func testFirstContainerCandidateWins() { + XCTAssertEqual( + JellyfinLibraryItem.resolveFileExtension(container: "m4b,mp4,mov", filePath: "/books/Dune.aax"), + "m4b", + "Jellyfin reports the container as a candidate list; the first is the real one" + ) + } + + func testPathExtensionCoversAMissingContainer() { + XCTAssertEqual( + JellyfinLibraryItem.resolveFileExtension(container: nil, filePath: "/books/Dune.m4b"), + "m4b" + ) + } + + func testNothingToDeriveFromStaysNil() { + XCTAssertNil(JellyfinLibraryItem.resolveFileExtension(container: nil, filePath: nil)) + XCTAssertEqual( + JellyfinLibraryItem.resolveFileExtension(container: nil, filePath: "/books/Dune"), + "", + "an extensionless path yields an empty string, which the import gate rejects" + ) + } +} + +// MARK: - Media-server chapter mapping + +/// A streamed item's chapters can only come from its server: nothing opens the file, and on +/// AudiobookShelf the list may be a server-side edit that isn't in the file at all. +final class MediaServerChapterMappingTests: XCTestCase { + // MARK: AudiobookShelf — start AND end, so durations are direct + + func testAudiobookShelfChaptersMapWithDirectDurations() { + let chapters = AudiobookShelfLibraryItem.chapterMetadata( + from: [ + .init(start: 0, end: 600, title: "One"), + .init(start: 600, end: 1500, title: "Two"), + ], + duration: 1500 + ) + + XCTAssertEqual(chapters.map(\.title), ["One", "Two"]) + XCTAssertEqual(chapters.map(\.start), [0, 600]) + XCTAssertEqual(chapters.map(\.duration), [600, 900]) + XCTAssertEqual(chapters.map(\.index), [1, 2], "index is 1-based, matching the playable list") + } + + func testAudiobookShelfOutOfOrderChaptersAreSortedAndZeroLengthOnesDropped() { + let chapters = AudiobookShelfLibraryItem.chapterMetadata( + from: [ + .init(start: 600, end: 1500, title: "Two"), + .init(start: 700, end: 700, title: "Empty"), + .init(start: 0, end: 600, title: "One"), + ], + duration: 1500 + ) + + XCTAssertEqual( + chapters.map(\.title), + ["One", "Two"], + "a zero-length entry would be filtered by getPlayableChapters anyway — don't store it" + ) + } + + func testAudiobookShelfNoChaptersIsEmptyNotASynthesizedOne() { + XCTAssertTrue(AudiobookShelfLibraryItem.chapterMetadata(from: nil, duration: 1500).isEmpty) + XCTAssertTrue(AudiobookShelfLibraryItem.chapterMetadata(from: [], duration: 1500).isEmpty) + } + + /// The bug this exists for: an uncovered tail resolves to NO chapter, so `PlayableItem.init` + /// falls back to `chapters[0]` and the session is pinned to chapter 1 — it never advances + /// (the tick only reassigns when `getChapter` finds one) and never reaches `chapters.last`, + /// so the book never completes. + func testAudiobookShelfLastChapterStretchesToTheItemDuration() { + let chapters = AudiobookShelfLibraryItem.chapterMetadata( + from: [ + .init(start: 0, end: 600, title: "One"), + .init(start: 600, end: 1400, title: "Two"), + ], + duration: 1500 + ) + + XCTAssertEqual( + chapters.last?.start.advanced(by: chapters.last?.duration ?? 0), + 1500, + "the chapter track stopping before the trailing silence must not leave a hole" + ) + } + + func testAudiobookShelfChapterEndBeyondTheDurationIsClamped() { + let chapters = AudiobookShelfLibraryItem.chapterMetadata( + from: [.init(start: 0, end: 9999, title: "Overlong")], + duration: 1500 + ) + + XCTAssertEqual(chapters.map(\.duration), [1500]) + } + + func testAudiobookShelfChaptersStartingPastTheDurationAreDropped() { + let chapters = AudiobookShelfLibraryItem.chapterMetadata( + from: [ + .init(start: 0, end: 600, title: "One"), + .init(start: 1600, end: 1700, title: "Bogus"), + ], + duration: 1500 + ) + + XCTAssertEqual(chapters.map(\.title), ["One"]) + XCTAssertEqual(chapters.map(\.duration), [1500], "the survivor still covers the item") + } + + func testAudiobookShelfWithoutADurationKeepsTheServersEnds() { + let chapters = AudiobookShelfLibraryItem.chapterMetadata( + from: [.init(start: 0, end: 600, title: "One")], + duration: nil + ) + + XCTAssertEqual(chapters.map(\.duration), [600]) + } + + // MARK: Jellyfin — starts only, so each duration is the gap to the next + + func testJellyfinChapterDurationsDeriveFromTheNextStart() { + let chapters = JellyfinLibraryItem.chapterMetadata( + from: [(name: "One", startTicks: 0), (name: "Two", startTicks: 6_000_000_000)], + runtimeSeconds: 1500 + ) + + XCTAssertEqual(chapters.map(\.title), ["One", "Two"]) + XCTAssertEqual(chapters.map(\.start), [0, 600]) + XCTAssertEqual( + chapters.map(\.duration), + [600, 900], + "the last chapter runs to the item runtime, which is the only thing that bounds it" + ) + } + + func testJellyfinChaptersNeedARuntimeToBeBounded() { + XCTAssertTrue( + JellyfinLibraryItem.chapterMetadata( + from: [(name: "One", startTicks: 0)], + runtimeSeconds: nil + ).isEmpty, + "without a runtime the final chapter has no end; storing a guess is worse than none" + ) + } + + func testJellyfinChaptersStartingPastTheRuntimeAreDropped() { + let chapters = JellyfinLibraryItem.chapterMetadata( + from: [(name: "One", startTicks: 0), (name: "Bogus", startTicks: 99_000_000_000)], + runtimeSeconds: 1500 + ) + + XCTAssertEqual(chapters.map(\.title), ["One"]) + XCTAssertEqual(chapters.map(\.duration), [1500]) + } + + func testJellyfinChaptersWithoutAStartAreSkipped() { + let chapters = JellyfinLibraryItem.chapterMetadata( + from: [(name: "Unanchored", startTicks: nil), (name: "One", startTicks: 0)], + runtimeSeconds: 600 + ) + + XCTAssertEqual(chapters.map(\.title), ["One"]) + } +} diff --git a/BookPlayerTests/ListSyncRefreshServiceTests.swift b/BookPlayerTests/ListSyncRefreshServiceTests.swift new file mode 100644 index 000000000..b167f6868 --- /dev/null +++ b/BookPlayerTests/ListSyncRefreshServiceTests.swift @@ -0,0 +1,121 @@ +// +// ListSyncRefreshServiceTests.swift +// BookPlayerTests +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import XCTest + +@testable import BookPlayer +@testable import BookPlayerKit + +/// First coverage for the one entry point every list refresh goes through. What it pins is +/// the ORDER: the media-server pull runs after the cloud sync of the same level (they write +/// on different contexts and must never overlap), for the same path, and regardless of how +/// the cloud step ended — the servers are independent hosts. +final class ListSyncRefreshServiceTests: XCTestCase { + /// Shared by the cloud mock and the pull stub so a test can read the sequence back. + private final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var _events: [String] = [] + + var events: [String] { + lock.lock() + defer { lock.unlock() } + return _events + } + + func record(_ event: String) { + lock.lock() + _events.append(event) + lock.unlock() + } + } + + private final class ProgressRefreshStub: ExternalProgressRefreshing { + let recorder: Recorder + + init(recorder: Recorder) { + self.recorder = recorder + } + + func refreshItems(at relativePath: String?) async { + recorder.record("pull:\(relativePath ?? "root")") + } + } + + private var recorder: Recorder! + private var syncService: SyncServiceProtocolMock! + private var preferencesService: PreferencesSyncServiceProtocolMock! + private var sut: ListSyncRefreshService! + + override func setUp() { + super.setUp() + recorder = Recorder() + syncService = SyncServiceProtocolMock() + // The first sync already ran: folder levels pull normally + syncService.hasRunFirstSync = true + syncService.syncListContentsAtClosure = { [recorder] path in + recorder?.record("cloud:\(path ?? "root")") + } + preferencesService = PreferencesSyncServiceProtocolMock() + sut = ListSyncRefreshService( + playerManager: PlayerManagerProtocolMock(), + syncService: syncService, + // Never reached: the loader only runs on the two last-book errors, which no test throws. + playerLoaderService: PlayerLoaderService(), + preferencesService: preferencesService, + externalProgressService: ProgressRefreshStub(recorder: recorder) + ) + } + + func testPullsTheLevelAfterItsCloudSync() async throws { + try await sut.syncList(at: "Author/Series") + + XCTAssertEqual(recorder.events, ["cloud:Author/Series", "pull:Author/Series"]) + XCTAssertEqual(preferencesService.pullFromServerForceCallsCount, 1) + } + + /// A cloud failure (network, server) is logged and swallowed; the media servers still get asked. + func testPullStillRunsWhenTheCloudSyncFails() async throws { + syncService.syncListContentsAtThrowableError = URLError(.notConnectedToInternet) + + try await sut.syncList(at: "Author/Series") + + XCTAssertEqual(recorder.events, ["pull:Author/Series"], "the closure never ran; the pull did") + XCTAssertEqual(preferencesService.pullFromServerForceCallsCount, 1) + } + + /// Until the first sync has registered this device's items, a folder's listing would delete + /// the ones the server hasn't seen (books imported while sync was off): its cloud step waits + func testFolderCloudSync_waitsForTheFirstSync() async throws { + syncService.hasRunFirstSync = false + + try await sut.syncList(at: "Author/Series") + + XCTAssertEqual(recorder.events, ["pull:Author/Series"]) + XCTAssertEqual(preferencesService.pullFromServerForceCallsCount, 1) + } + + /// The root refresh asks for the weekly / became-PRO pass, without waiting on it + func testRootRefresh_asksForTheMissingItemsPass() async throws { + let asked = expectation(description: "pass asked") + syncService.scheduleMissingItemsIfNeededClosure = { asked.fulfill() } + + try await sut.syncList(at: nil) + + await fulfillment(of: [asked], timeout: 1) + XCTAssertEqual(recorder.events.first, "cloud:root") + } + + func testFolderRefresh_neverAsksForThePass() async throws { + let asked = expectation(description: "pass asked") + asked.isInverted = true + syncService.scheduleMissingItemsIfNeededClosure = { asked.fulfill() } + + try await sut.syncList(at: "Author") + + await fulfillment(of: [asked], timeout: 0.3) + } +} diff --git a/BookPlayerTests/Mocks/AccountServiceMock.swift b/BookPlayerTests/Mocks/AccountServiceMock.swift index 0dbabd8dc..d03a82b66 100644 --- a/BookPlayerTests/Mocks/AccountServiceMock.swift +++ b/BookPlayerTests/Mocks/AccountServiceMock.swift @@ -21,14 +21,24 @@ class AccountServiceMock: AccountServiceProtocol { return nil } + /// Settable so a test can model lite/pro (true) vs free/plus (false); defaults to free. + var hasSyncEnabledValue = false + func hasSyncEnabled() -> Bool { - return false + return hasSyncEnabledValue } func hasPlusAccess() -> Bool { return false } + /// Settable so a test can model a lapsed subscriber — paid once, no longer subscribed. + var hasEverSubscribedValue = false + + func hasEverSubscribed() -> Bool { + return hasEverSubscribedValue + } + func getSecondOnboarding() async throws -> T { throw BookPlayerError.cancelledTask } @@ -39,8 +49,13 @@ class AccountServiceMock: AccountServiceProtocol { self.account = account } + /// Mirrors the real `getAccountId()`, which maps an empty id to nil. Without that, a mock + /// account in the logged-out state — blank id, row still present — reports `Optional("")` + /// and reads as signed IN, which is exactly the state the streaming gate has to catch. func getAccountId() -> String? { - return self.account?.id + guard let id = self.account?.id, !id.isEmpty else { return nil } + + return id } func getAccount() -> Account? { @@ -122,4 +137,13 @@ class AccountServiceMock: AccountServiceProtocol { func logout() throws {} func deleteAccount() async throws -> String { return "Success" } + + /// Settable so a test can model a tier change. The default .free keeps this consistent + /// with the mock's hasSyncEnabled()/hasPlusAccess() both returning false — a .plus level + /// would contradict them. + var accessLevelValue: BookPlayerKit.AccessLevel = .free + + func getAccessLevel() -> BookPlayerKit.AccessLevel { + return accessLevelValue + } } diff --git a/BookPlayerTests/Mocks/PlayableItemMock.swift b/BookPlayerTests/Mocks/PlayableItemMock.swift index 82f8d79c9..89aa44248 100644 --- a/BookPlayerTests/Mocks/PlayableItemMock.swift +++ b/BookPlayerTests/Mocks/PlayableItemMock.swift @@ -22,6 +22,7 @@ extension PlayableItem { duration: 100, relativePath: "test-path", remoteURL: nil, + externalURL: nil, index: 0 ) ], @@ -49,6 +50,7 @@ extension PlayableItem { duration: 100, relativePath: "test-path", remoteURL: nil, + externalURL: nil, index: 0 ) ], diff --git a/BookPlayerTests/NetworkClientQueryEncodingTests.swift b/BookPlayerTests/NetworkClientQueryEncodingTests.swift new file mode 100644 index 000000000..e1defd7f8 --- /dev/null +++ b/BookPlayerTests/NetworkClientQueryEncodingTests.swift @@ -0,0 +1,63 @@ +// +// NetworkClientQueryEncodingTests.swift +// BookPlayerTests +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import XCTest + +@testable import BookPlayerKit + +/// GET parameters travel as `[String: Any]`. An optional stored as `Any` used to be +/// interpolated into the query string as `Optional("…")` — or the literal `nil` — +/// which is what the API received for every `uuid` on `/v1/library` for years and +/// silently ignored. The encoder must unwrap optionals and drop absent values. +final class NetworkClientQueryEncodingTests: XCTestCase { + func testOptionalGetParametersAreUnwrappedAndAbsentOnesDropped() throws { + let client = NetworkClient() + let uuid: String? = "2C2D0F44-1111-4111-8111-111111111111" + let missing: String? = nil + + let request = try client.buildURLRequest( + path: "/v1/library", + method: .get, + parameters: [ + "relativePath": "Folder/", + "sign": true, + "uuid": uuid as Any, + "missing": missing as Any, + ] + ) + + let url = try XCTUnwrap(request.url) + let items = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + let byName = Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value ?? "") }) + + XCTAssertEqual(byName["uuid"], uuid, "the wrapped value, not Optional(\"…\")") + XCTAssertEqual(byName["relativePath"], "Folder/") + XCTAssertEqual(byName["sign"], "true") + XCTAssertNil(byName["missing"], "a nil optional is omitted, not sent as the string \"nil\"") + XCTAssertFalse(url.absoluteString.contains("Optional")) + XCTAssertFalse(url.absoluteString.contains("nil")) + } + + /// The API decodes a literal `+` in a query value as a space, so it must travel as %2B: + /// file names ("A+B.mp3") and S3 upload ids can both carry one + func testPlusSignsAreEncodedSoTheServerDoesNotReadThemAsSpaces() throws { + let client = NetworkClient() + + let request = try client.buildURLRequest( + path: "/v1/library/upload/parts", + method: .get, + parameters: ["uploadId": "a+b/c=d", "relativePath": "A+B.mp3"] + ) + + let url = try XCTUnwrap(request.url) + XCTAssertFalse(url.absoluteString.contains("+")) + let items = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + let byName = Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value ?? "") }) + XCTAssertEqual(byName["uploadId"], "a+b/c=d") + XCTAssertEqual(byName["relativePath"], "A+B.mp3") + } +} diff --git a/BookPlayerTests/PlayableChapterTests.swift b/BookPlayerTests/PlayableChapterTests.swift index d1b4ba30b..167db7be1 100644 --- a/BookPlayerTests/PlayableChapterTests.swift +++ b/BookPlayerTests/PlayableChapterTests.swift @@ -20,6 +20,7 @@ class PlayableChapterTests: XCTestCase { duration: 1, relativePath: relativePath, remoteURL: nil, + externalURL: nil, index: 0 ) } @@ -60,4 +61,35 @@ class PlayableChapterTests: XCTestCase { XCTAssertFalse(makeChapter(relativePath: "folder/no_extension").isVideo) XCTAssertFalse(makeChapter(relativePath: "").isVideo) } + + /// Regression guard for the CodingKeys exclusion: encoded chapters travel through the + /// WatchConnectivity application context, which the system persists to disk on both + /// devices — the media server's live Authorization token must never be in that payload. + func testCodableExcludesExternalUrlAndHeaders() throws { + let chapter = PlayableChapter( + title: "test", + author: "test", + start: 0, + duration: 1, + relativePath: "book.m4b", + remoteURL: URL(string: "https://cloud.example.com/book.m4b"), + externalURL: URL(string: "https://jellyfin.local/Items/abc/Download?api_key=SECRET-TOKEN"), + index: 0, + externalHeaders: ["Authorization": "Bearer SECRET-TOKEN"] + ) + + let data = try JSONEncoder().encode(chapter) + let json = String(decoding: data, as: UTF8.self) + + XCTAssertFalse(json.contains("SECRET-TOKEN"), "the media-server token leaked into the encoded payload") + XCTAssertFalse(json.contains("Authorization"), "externalHeaders leaked into the encoded payload") + XCTAssertFalse(json.contains("externalUrl"), "externalUrl leaked into the encoded payload") + XCTAssertFalse(json.contains("jellyfin.local"), "the external URL leaked into the encoded payload") + // The cloud remoteURL is NOT sensitive and must still round-trip + XCTAssertTrue(json.contains("cloud.example.com")) + + let decoded = try JSONDecoder().decode(PlayableChapter.self, from: data) + XCTAssertNil(decoded.externalUrl) + XCTAssertTrue(decoded.externalHeaders.isEmpty) + } } diff --git a/BookPlayerTests/PlayableItemTests.swift b/BookPlayerTests/PlayableItemTests.swift index 222d004a0..2809a36ac 100644 --- a/BookPlayerTests/PlayableItemTests.swift +++ b/BookPlayerTests/PlayableItemTests.swift @@ -24,6 +24,7 @@ class PlayableItemTests: XCTestCase { duration: 50, relativePath: "", remoteURL: nil, + externalURL: nil, index: 1 ) let testChapter2 = PlayableChapter( @@ -33,6 +34,7 @@ class PlayableItemTests: XCTestCase { duration: 100, relativePath: "", remoteURL: nil, + externalURL: nil, index: 1 ) self.sut = PlayableItem( @@ -149,6 +151,7 @@ class PlayableItemTests: XCTestCase { duration: duration, relativePath: "book.m4b", remoteURL: nil, + externalURL: nil, index: index ) } diff --git a/BookPlayerTests/PlayerManagerTests.swift b/BookPlayerTests/PlayerManagerTests.swift index e9191b628..332b58323 100644 --- a/BookPlayerTests/PlayerManagerTests.swift +++ b/BookPlayerTests/PlayerManagerTests.swift @@ -16,6 +16,11 @@ import XCTest class PlayerManagerTests: XCTestCase { var playbackServiceMock: PlaybackServiceProtocolMock! + var syncServiceMock: SyncServiceProtocolMock! + /// Read live by `sut`'s entitlement closure. A test flips this rather than building a second + /// `PlayerManager`: two of them means two AVPlayers, and the one going out of scope takes its + /// still-registered periodic time observer with it, which aborts the process. + var streamingEnabled = true var sut: PlayerManager! override func setUp() { @@ -29,13 +34,18 @@ class PlayerManagerTests: XCTestCase { self.playbackServiceMock.updatePlaybackTimeItemTimeClosure = { item, time in item.currentTime = time } + self.syncServiceMock = SyncServiceProtocolMock() self.sut = PlayerManager( libraryService: LibraryServiceProtocolMock(), playbackService: playbackServiceMock, - syncService: SyncServiceProtocolMock(), + syncService: syncServiceMock, speedService: SpeedServiceProtocolMock(), shakeMotionService: ShakeMotionServiceProtocolMock(), - widgetReloadService: WidgetReloadService() + widgetReloadService: WidgetReloadService(), + // Entitled by default, so nothing here is suppressed by tier; the item half of the rule + // is covered by MediaServersShortcutTests against PlayableChapter. + hasStreamingEnabled: { [weak self] in self?.streamingEnabled ?? true }, + presentFailure: { _ in } ) } @@ -47,6 +57,7 @@ class PlayerManagerTests: XCTestCase { duration: 50, relativePath: "", remoteURL: nil, + externalURL: nil, index: 0 ) let testChapter2 = PlayableChapter( @@ -56,6 +67,7 @@ class PlayerManagerTests: XCTestCase { duration: 100, relativePath: "", remoteURL: nil, + externalURL: nil, index: 1 ) return PlayableItem( @@ -84,6 +96,7 @@ class PlayerManagerTests: XCTestCase { duration: 50, relativePath: "", remoteURL: nil, + externalURL: nil, index: 1 ) let chapter2 = PlayableChapter( @@ -93,6 +106,7 @@ class PlayerManagerTests: XCTestCase { duration: 100, relativePath: "", remoteURL: nil, + externalURL: nil, index: 2 ) return PlayableItem( @@ -111,6 +125,189 @@ class PlayerManagerTests: XCTestCase { ) } + /// The tier half of the Media Servers shortcut. The item half lives on PlayableChapter + /// (MediaServersShortcutTests); this pins the wiring — that the entitlement closure is + /// actually consulted — against a real PlayerManager. + private func makeUnplayableExternalChapter() -> PlayableChapter { + PlayableChapter( + title: "test chapter", + author: "test author", + start: 0, + duration: 50, + relativePath: "no-such-file.m4b", + remoteURL: URL(string: "https://s3.example.com/presigned"), + externalURL: nil, + index: 0, + hasUnresolvedExternalHost: true + ) + } + + /// An item whose host has no saved connection is a DIFFERENT problem from a server that + /// won't answer, and only the surfaces can word that difference — so the reason has to be + /// derived correctly here. Order matters: `needsMediaServer()` is true for both shapes. + func testAnUnresolvedHostIsAMissingConnectionNotAnUnreachableStream() { + let failure = sut.playbackFailure( + for: makeUnplayableExternalChapter(), + title: "t", + message: "m" + ) + + XCTAssertEqual(failure.reason, .missingConnection) + XCTAssertTrue(failure.canOfferMediaServers) + } + + /// The case the derivation order exists for. `hasUnresolvedExternalHost` is a stored flag + /// that stays true on a book you already DOWNLOADED from a server you later removed, so + /// reading it first blamed a missing server for a local file that simply won't open — and + /// produced a reason that disagreed with `canOfferMediaServers` on the same value. + func testADownloadedBookIsNeverBlamedOnAMissingServer() throws { + let relativePath = "downloaded-from-a-removed-server.m4b" + let fileURL = DataManager.getProcessedFolderURL().appendingPathComponent(relativePath) + try Data("not really audio".utf8).write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let chapter = PlayableChapter( + title: "test chapter", + author: "test author", + start: 0, + duration: 50, + relativePath: relativePath, + remoteURL: nil, + externalURL: nil, + index: 0, + hasUnresolvedExternalHost: true + ) + + let failure = sut.playbackFailure(for: chapter, title: "t", message: "m") + + XCTAssertEqual(failure.reason, .other, "the file is on disk — no server is involved") + XCTAssertFalse( + failure.canOfferMediaServers, + "reason and the shortcut must not disagree on the same failure" + ) + } + + func testAResolvedStreamThatFailedIsUnavailableNotMissing() { + let chapter = PlayableChapter( + title: "test chapter", + author: "test author", + start: 0, + duration: 50, + relativePath: "no-such-file.m4b", + remoteURL: nil, + externalURL: URL(string: "https://jellyfin.example.com/stream"), + index: 0, + hasUnresolvedExternalHost: false + ) + + XCTAssertEqual( + sut.playbackFailure(for: chapter, title: "t", message: "m").reason, + .streamUnavailable + ) + } + + /// No chapter at all (the player item failed before one was resolved) must not claim a + /// media-server problem, or the surfaces offer a fix for something else entirely. + func testAFailureWithNoChapterOffersNothing() { + let failure = sut.playbackFailure(for: nil, title: "t", message: nil) + + XCTAssertEqual(failure.reason, .other) + XCTAssertFalse(failure.canOfferMediaServers) + } + + /// The phone's copy is deliberately passed through untouched — the error code and NSError + /// dump are worth keeping for support threads. + func testThePhoneCopyIsCarriedVerbatim() { + let failure = sut.playbackFailure(for: nil, title: "Error 1234", message: "userInfo={...}") + + XCTAssertEqual(failure.phoneTitle, "Error 1234") + XCTAssertEqual(failure.phoneMessage, "userInfo={...}") + } + + func testMediaServersShortcutOfferedWhenStreamingIsEnabled() { + XCTAssertTrue(self.sut.offersMediaServers(for: makeUnplayableExternalChapter())) + } + + func testMediaServersShortcutSuppressedWithoutStreaming() { + let unentitled = PlayerManager( + libraryService: LibraryServiceProtocolMock(), + playbackService: playbackServiceMock, + syncService: SyncServiceProtocolMock(), + speedService: SpeedServiceProtocolMock(), + shakeMotionService: ShakeMotionServiceProtocolMock(), + widgetReloadService: WidgetReloadService(), + hasStreamingEnabled: { false }, + presentFailure: { _ in } + ) + + XCTAssertFalse( + unentitled.offersMediaServers(for: makeUnplayableExternalChapter()), + "a tier that can't stream has nothing to gain from the shortcut" + ) + } + + /// The gate that makes the entitlement enforceable at all: before it, the external branch + /// had no check, so a row that outlived its subscription kept streaming. + /// + /// `syncService.isActive` is forced true only to make the diversion observable — in + /// production it implies an active pro/lite, which implies streaming access, so the two can + /// never disagree this way. What is being pinned is that the external branch is skipped. + @MainActor + func testAnUnentitledUserDoesNotStreamAnExternalChapter() async throws { + streamingEnabled = false + syncServiceMock.isActive = true + // Fail the presign through the CLOSURE, not `ThrowableError`: the generated mock throws + // before it increments its call count, which would make the assertion below unfalsifiable. + // `RemoteFileURL` is decode-only, so returning a stub response isn't an option. + syncServiceMock.getRemoteFileURLsOfForTypeClosure = { _, _, _ in + throw BookPlayerError.cancelledTask + } + let chapter = PlayableChapter( + title: "test chapter", + author: "test author", + start: 0, + duration: 100, + relativePath: "no-such-file.m4b", + remoteURL: nil, + externalURL: URL(string: "https://jelly.example.com/stream"), + index: 1 + ) + + _ = try? await sut.loadPlayerItem(for: chapter, forceRefreshURL: false) + + XCTAssertEqual( + syncServiceMock.getRemoteFileURLsOfForTypeCallsCount, + 1, + "without streaming access the external branch is skipped, so the load falls through it" + ) + } + + /// Branch order in `loadPlayerItem`: a media-server chapter streams from its own URL even + /// when the caller forces a refresh. Inverting the external and cloud branches would send + /// a Jellyfin/ABS book to the S3 presign endpoint, which has nothing to hand back for it. + @MainActor + func testForcedRefreshKeepsAnExternalChapterOnItsStreamURL() async throws { + syncServiceMock.isActive = true + let chapter = PlayableChapter( + title: "test chapter", + author: "test author", + start: 0, + duration: 100, + relativePath: "no-such-file.m4b", + remoteURL: URL(string: "https://s3.example.com/presigned"), + externalURL: URL(string: "https://jelly.example.com/stream"), + index: 1 + ) + + _ = try await sut.loadPlayerItem(for: chapter, forceRefreshURL: true) + + XCTAssertEqual( + syncServiceMock.getRemoteFileURLsOfForTypeCallsCount, + 0, + "the cloud presign path must never be consulted for a chapter that has a stream URL" + ) + } + func testUpdatingEmptyNowPlayingBookTime() { self.sut.setNowPlayingBookTime() diff --git a/BookPlayerTests/PromptSurfaceArbiterTests.swift b/BookPlayerTests/PromptSurfaceArbiterTests.swift new file mode 100644 index 000000000..f79c21468 --- /dev/null +++ b/BookPlayerTests/PromptSurfaceArbiterTests.swift @@ -0,0 +1,366 @@ +// +// PromptSurfaceArbiterTests.swift +// BookPlayerTests +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +@testable import BookPlayer +@testable import BookPlayerKit +import XCTest + +// MARK: - Resume offer routing + +/// One surface asks, never two: the arbiter is the single subscriber to the service. +@MainActor +final class PromptSurfaceArbiterTests: XCTestCase { + private final class PresenterSpy: ResumeOfferPresenting, PlaybackFailurePresenting { + var presentedTimes: [TimeInterval] = [] + var presentedFailures: [PlaybackFailure] = [] + /// False stands in for a registered car whose interface controller has gone away. + var canPresent = true + + func presentResumeOffer(at remoteTime: TimeInterval) -> Bool { + guard canPresent else { return false } + presentedTimes.append(remoteTime) + return true + } + + func presentPlaybackFailure(_ failure: PlaybackFailure) -> Bool { + guard canPresent else { return false } + presentedFailures.append(failure) + return true + } + } + + private let position = ExternalPlaybackProgress(currentTime: 480, lastPlayedDate: Date()) + + func testWithoutCarPlayThePhoneGetsTheOffer() { + let playerState = PlayerState() + let sut = PromptSurfaceArbiter(playerState: playerState, isAppActive: { false }) + + sut.route(position) + + XCTAssertTrue(playerState.showResumePopup) + XCTAssertEqual(playerState.remotePlayTime, 480) + } + + /// The driving case: phone in a pocket, car connected. The car asks and the phone's flag is + /// never raised, so unlocking the phone later cannot ask the same question again. + func testCarPlayGetsTheOfferWhenTheAppIsInactive() { + let playerState = PlayerState() + let car = PresenterSpy() + let sut = PromptSurfaceArbiter(playerState: playerState, isAppActive: { false }) + sut.carPlayPresenter = car + + sut.route(position) + + XCTAssertEqual(car.presentedTimes, [480]) + XCTAssertFalse(playerState.showResumePopup, "an offer routed to the car leaves no phone flag behind") + XCTAssertNil(playerState.remotePlayTime) + } + + /// Phone in hand with the car connected: the SwiftUI alert is the better surface, and the + /// car must stay quiet rather than ask in parallel. + func testThePhoneWinsWhenTheAppIsActiveEvenWithCarPlayConnected() { + let playerState = PlayerState() + let car = PresenterSpy() + let sut = PromptSurfaceArbiter(playerState: playerState, isAppActive: { true }) + sut.carPlayPresenter = car + + sut.route(position) + + XCTAssertTrue(car.presentedTimes.isEmpty) + XCTAssertTrue(playerState.showResumePopup) + } + + func testAnOfferAlreadyShowingIsNotClobbered() { + let playerState = PlayerState() + playerState.showResumePopup = true + playerState.remotePlayTime = 120 + let sut = PromptSurfaceArbiter(playerState: playerState, isAppActive: { true }) + + sut.route(position) + + XCTAssertEqual(playerState.remotePlayTime, 120, "the prompt the user is looking at keeps its position") + } + + /// Disconnecting the car must hand the next offer back to the phone. + func testAReleasedPresenterFallsBackToThePhone() { + let playerState = PlayerState() + let sut = PromptSurfaceArbiter(playerState: playerState, isAppActive: { false }) + var car: PresenterSpy? = PresenterSpy() + sut.carPlayPresenter = car + car = nil + + sut.route(position) + + XCTAssertTrue(playerState.showResumePopup, "a weak presenter that went away is the same as none") + } +} + +// MARK: - Playback failure routing + +/// Same surface rule as the resume offer, deliberately: a second copy of "which surface may +/// speak" is the drift the arbiter exists to prevent. +@MainActor +final class PlaybackFailureRoutingTests: XCTestCase { + private final class PresenterSpy: ResumeOfferPresenting, PlaybackFailurePresenting { + var presentedFailures: [PlaybackFailure] = [] + var canPresent = true + + func presentResumeOffer(at remoteTime: TimeInterval) -> Bool { canPresent } + + func presentPlaybackFailure(_ failure: PlaybackFailure) -> Bool { + guard canPresent else { return false } + presentedFailures.append(failure) + return true + } + } + + private let failure = PlaybackFailure( + reason: .streamUnavailable, + phoneTitle: "Error 1234", + phoneMessage: "the underlying NSError dump", + canOfferMediaServers: true + ) + + func testWithoutCarPlayTheFailureWaitsOnThePhone() { + let playerState = PlayerState() + let sut = PromptSurfaceArbiter(playerState: playerState, isAppActive: { false }) + + sut.routeFailure(failure) + + XCTAssertEqual(playerState.pendingFailure, failure) + } + + /// The case the whole change exists for: streaming fails in the car with the phone locked. + /// The old code walked to a foregroundActive UIWindowScene, found none, and dropped it. + func testCarPlayGetsTheFailureWhenTheAppIsInactive() { + let playerState = PlayerState() + let car = PresenterSpy() + let sut = PromptSurfaceArbiter(playerState: playerState, isAppActive: { false }) + sut.carPlayPresenter = car + + sut.routeFailure(failure) + + XCTAssertEqual(car.presentedFailures, [failure]) + XCTAssertNil(playerState.pendingFailure, "a failure routed to the car leaves nothing behind") + } + + /// Phone in hand with the car connected — a passenger tapping around, or parked. The car + /// stays quiet on purpose: CPAlertTemplate is modal on the dashboard until someone taps it. + func testThePhoneWinsWhenTheAppIsActiveEvenWithCarPlayConnected() { + let playerState = PlayerState() + let car = PresenterSpy() + let sut = PromptSurfaceArbiter(playerState: playerState, isAppActive: { true }) + sut.carPlayPresenter = car + + sut.routeFailure(failure) + + XCTAssertTrue(car.presentedFailures.isEmpty) + XCTAssertEqual(playerState.pendingFailure, failure) + } + + /// Registered but unable to present: the interface controller went away between connect and + /// here. An error must fall back to the phone rather than vanish. + func testACarThatCannotPresentFallsBackToThePhone() { + let playerState = PlayerState() + let car = PresenterSpy() + car.canPresent = false + let sut = PromptSurfaceArbiter(playerState: playerState, isAppActive: { false }) + sut.carPlayPresenter = car + + sut.routeFailure(failure) + + XCTAssertTrue(car.presentedFailures.isEmpty) + XCTAssertEqual(playerState.pendingFailure, failure, "a declined presentation is not a delivered one") + } + + func testAReleasedPresenterFallsBackToThePhone() { + let playerState = PlayerState() + let sut = PromptSurfaceArbiter(playerState: playerState, isAppActive: { false }) + var car: PresenterSpy? = PresenterSpy() + sut.carPlayPresenter = car + car = nil + + sut.routeFailure(failure) + + XCTAssertEqual(playerState.pendingFailure, failure) + } + + /// Unlike the resume offer, a newer failure replaces an older one: the second attempt is + /// what the user just did, so its error is the relevant one. + func testANewerFailureReplacesThePendingOne() { + let playerState = PlayerState() + let sut = PromptSurfaceArbiter(playerState: playerState, isAppActive: { true }) + sut.routeFailure(failure) + + let newer = PlaybackFailure( + reason: .missingConnection, + phoneTitle: "Error 5678", + phoneMessage: nil, + canOfferMediaServers: false + ) + sut.routeFailure(newer) + + XCTAssertEqual(playerState.pendingFailure, newer) + } +} + +// MARK: - Surface-specific copy + +final class PlaybackFailureCopyTests: XCTestCase { + private static let phoneTitleSentinel = "Error 1234" + private static let phoneMessageSentinel = "NSCocoaErrorDomain userInfo={dump}" + + private func failure(_ reason: PlaybackFailure.Reason) -> PlaybackFailure { + PlaybackFailure( + reason: reason, + phoneTitle: Self.phoneTitleSentinel, + phoneMessage: Self.phoneMessageSentinel, + canOfferMediaServers: true + ) + } + + /// The car never shows an error code or an NSError dump, and it points at the one device + /// where a media server can actually be configured. + func testTheCarGetsItsOwnLineNotTheTechnicalDetail() { + for reason in [PlaybackFailure.Reason.missingConnection, .streamUnavailable, .other] { + let message = failure(reason).carPlayMessage + + XCTAssertFalse(message.isEmpty) + XCTAssertFalse( + message.contains(Self.phoneTitleSentinel), + "an error code has no business on a dashboard" + ) + XCTAssertFalse( + message.contains(Self.phoneMessageSentinel), + "the phone's technical detail must not reach the dashboard" + ) + } + } + + /// A host with no saved connection is a different problem from a server that won't answer, + /// and the car words it differently. + /// + /// Asserts the key RESOLVES as well as matching: `.localized` hands back the key itself when + /// the entry is missing, so comparing two `.localized` calls alone would still pass after + /// someone deleted the string. + func testAMissingConnectionGetsItsOwnExplanation() { + let missing = failure(.missingConnection).carPlayMessage + let unavailable = failure(.streamUnavailable).carPlayMessage + + XCTAssertEqual(missing, "carplay_missing_connection_message".localized) + XCTAssertNotEqual(missing, "carplay_missing_connection_message", "the string is missing") + + XCTAssertEqual(unavailable, "carplay_playback_failed_message".localized) + XCTAssertNotEqual(unavailable, "carplay_playback_failed_message", "the string is missing") + + XCTAssertNotEqual(missing, unavailable, "the two problems read the same to the driver") + } + + /// The fallback is what `.other` gets — a local file that won't open, no server involved — + /// and what an unentitled user gets. It must not send either of them to look at a media + /// server, which is the whole point of gating the other string. + func testTheFallbackGivesNoMediaServerAdvice() { + let fallback = failure(.other).carPlayMessage + + XCTAssertFalse(fallback.localizedCaseInsensitiveContains("media server")) + XCTAssertFalse(fallback.localizedCaseInsensitiveContains("connect")) + } + + /// The car must not tell a tier that cannot stream to go connect a server — the phone hides + /// its shortcut for that exact user, and the two surfaces have to agree. + func testAnUnentitledUserIsNotToldToConnectAServer() { + let unentitled = PlaybackFailure( + reason: .missingConnection, + phoneTitle: Self.phoneTitleSentinel, + phoneMessage: Self.phoneMessageSentinel, + canOfferMediaServers: false + ) + + XCTAssertEqual(unentitled.carPlayMessage, "carplay_playback_failed_message".localized) + } +} + +// MARK: - Which alert gets the one slot + +/// SwiftUI presents one alert per view, and both prompts hang off the same two attachment +/// points — so the bindings, not luck, have to decide which one appears. +@MainActor +final class PromptBindingPrecedenceTests: XCTestCase { + func testAPendingFailureSuppressesTheResumeOffer() { + let playerState = PlayerState() + playerState.showResumePopup = true + playerState.pendingFailure = PlaybackFailure( + reason: .streamUnavailable, + phoneTitle: "Error 1234", + phoneMessage: nil, + canOfferMediaServers: false + ) + + XCTAssertFalse( + playerState.showResumePopupBinding(whenPlayerVisible: false).wrappedValue, + "two live bindings would drop one alert with its flag still set" + ) + XCTAssertTrue(playerState.pendingFailureBinding(whenPlayerVisible: false).wrappedValue) + } + + /// The offer is deferred, not discarded. + func testTheResumeOfferReturnsOnceTheFailureIsDismissed() { + let playerState = PlayerState() + playerState.showResumePopup = true + playerState.pendingFailure = PlaybackFailure( + reason: .other, + phoneTitle: "t", + phoneMessage: nil, + canOfferMediaServers: false + ) + + playerState.pendingFailureBinding(whenPlayerVisible: false).wrappedValue = false + + XCTAssertNil(playerState.pendingFailure) + XCTAssertTrue(playerState.showResumePopupBinding(whenPlayerVisible: false).wrappedValue) + } + + /// Only the copy in the live presentation context may present. + func testOnlyOneContextCopyIsEverEligible() { + let playerState = PlayerState() + playerState.pendingFailure = PlaybackFailure( + reason: .other, + phoneTitle: "t", + phoneMessage: nil, + canOfferMediaServers: false + ) + + playerState.isShowingPlayer = false + XCTAssertTrue(playerState.pendingFailureBinding(whenPlayerVisible: false).wrappedValue) + XCTAssertFalse(playerState.pendingFailureBinding(whenPlayerVisible: true).wrappedValue) + + playerState.isShowingPlayer = true + XCTAssertFalse(playerState.pendingFailureBinding(whenPlayerVisible: false).wrappedValue) + XCTAssertTrue(playerState.pendingFailureBinding(whenPlayerVisible: true).wrappedValue) + } + + func testClearPromptsRetiresBoth() { + let playerState = PlayerState() + playerState.showResumePopup = true + playerState.remotePlayTime = 480 + playerState.pendingFailure = PlaybackFailure( + reason: .other, + phoneTitle: "t", + phoneMessage: nil, + canOfferMediaServers: false + ) + + playerState.clearPrompts() + + XCTAssertFalse(playerState.showResumePopup) + XCTAssertNil(playerState.remotePlayTime) + XCTAssertNil(playerState.pendingFailure) + } +} + diff --git a/BookPlayerTests/QueuedTaskDisplayTests.swift b/BookPlayerTests/QueuedTaskDisplayTests.swift new file mode 100644 index 000000000..448324f07 --- /dev/null +++ b/BookPlayerTests/QueuedTaskDisplayTests.swift @@ -0,0 +1,109 @@ +// +// QueuedTaskDisplayTests.swift +// BookPlayerTests +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import XCTest + +@testable import BookPlayer +@testable import BookPlayerKit + +/// The sectioned Queued Tasks screen is pure shaping over the engine's task list: lane +/// order, per-row copy and icon, and which rows follow byte progress. +final class QueuedTaskDisplayTests: XCTestCase { + private func task( + _ id: String, + lane: String, + jobType: SyncJobType = .externalUpdate, + uuid: String = "", + relativePath: String = "" + ) -> QueuedSyncTask { + QueuedSyncTask( + id: id, + queueKey: lane, + jobType: jobType, + parameters: [:], + uuid: uuid, + relativePath: relativePath + ) + } + + func testGroupedByLane_syncFirstThenAlphabetical_keepingEngineOrder() { + let tasks = [ + task("j2", lane: "jellyfin"), + task("u1", lane: TaskQueueKey.uploadFile, jobType: .uploadFile), + task("a1", lane: "audiobookshelf"), + task("s1", lane: TaskQueueKey.sync, jobType: .update), + task("j1", lane: "jellyfin"), + ] + + let sections = tasks.groupedByLane() + + XCTAssertEqual( + sections.map(\.queueKey), + [TaskQueueKey.sync, "audiobookshelf", "jellyfin", TaskQueueKey.uploadFile] + ) + XCTAssertEqual(sections[2].tasks.map(\.id), ["j2", "j1"], "a lane keeps the engine's order") + } + + func testGroupedByLane_drainedLanesHaveNoSection() { + XCTAssertTrue([QueuedSyncTask]().groupedByLane().isEmpty) + XCTAssertEqual([task("j1", lane: "jellyfin")].groupedByLane().map(\.queueKey), ["jellyfin"]) + } + + func testDisplayTitle_perJobType() { + XCTAssertEqual( + task("s", lane: TaskQueueKey.sync, jobType: .matchUuid).displayTitle, + "sync_library_title".localized + ) + XCTAssertEqual( + task("s", lane: TaskQueueKey.sync, jobType: .update, relativePath: "Author/Book.m4b").displayTitle, + "Author/Book.m4b" + ) + XCTAssertEqual( + task("u", lane: TaskQueueKey.uploadFile, jobType: .uploadFile, relativePath: "Author/Book.m4b").displayTitle, + "Author/Book.m4b" + ) + XCTAssertEqual( + task("u", lane: TaskQueueKey.uploadFile, jobType: .uploadFile).displayTitle, + "task_uploading_file_label".localized, + "a task stored before the key was persisted still reads as an upload" + ) + XCTAssertEqual( + task("j", lane: "jellyfin").displayTitle, + String(format: "task_updating_progress_label".localized, "Jellyfin") + ) + } + + func testDisplayImageName_perJobType() { + XCTAssertEqual( + task("u", lane: TaskQueueKey.uploadFile, jobType: .uploadFile).displayImageName, + "square.and.arrow.up.badge.clock" + ) + XCTAssertEqual(task("s", lane: TaskQueueKey.sync, jobType: .setBookmark).displayImageName, "bookmark") + XCTAssertEqual( + task("s", lane: TaskQueueKey.sync, jobType: .matchUuid).displayImageName, + "app.connected.to.app.below.fill" + ) + XCTAssertEqual(task("j", lane: "jellyfin").displayImageName, "arrow.2.circlepath") + } + + func testTracksByteProgress_onlyForFileUploads() { + XCTAssertTrue(task("u", lane: TaskQueueKey.uploadFile, jobType: .uploadFile).tracksByteProgress) + XCTAssertTrue(task("s", lane: TaskQueueKey.sync, jobType: .upload).tracksByteProgress) + XCTAssertFalse(task("s", lane: TaskQueueKey.sync, jobType: .update).tracksByteProgress) + XCTAssertFalse(task("j", lane: "jellyfin").tracksByteProgress) + } + + /// Rows and `.uploadProgressUpdated` must agree on the key: a real uuid, else the path. + func testProgressKey_matchesTheProgressNotificationKey() { + let uuid = UUID().uuidString + XCTAssertEqual(task("a", lane: TaskQueueKey.sync, uuid: uuid, relativePath: "A.m4b").progressKey, uuid) + XCTAssertEqual( + task("b", lane: TaskQueueKey.sync, uuid: Constants.uuidPlaceholder, relativePath: "B.m4b").progressKey, + "B.m4b" + ) + } +} diff --git a/BookPlayerTests/RefreshTaskOperationTests.swift b/BookPlayerTests/RefreshTaskOperationTests.swift new file mode 100644 index 000000000..f68b86e4b --- /dev/null +++ b/BookPlayerTests/RefreshTaskOperationTests.swift @@ -0,0 +1,89 @@ +// +// RefreshTaskOperationTests.swift +// BookPlayerTests +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Combine +import XCTest + +@testable import BookPlayer +@testable import BookPlayerKit + +/// First coverage for the background-refresh keep-alive: it must hold the window open +/// exactly until the watched queue reports drained, and never finish twice. +final class RefreshTaskOperationTests: XCTestCase { + func testFinishesImmediatelyWhenAlreadyDrained() { + let drained = CurrentValueSubject(true) + let operation = RefreshTaskOperation(queueDrained: drained.eraseToAnyPublisher()) + + operation.start() + + XCTAssertTrue(operation.isFinished) + XCTAssertFalse(operation.isExecuting) + } + + func testStaysAliveUntilDrained() { + let drained = CurrentValueSubject(false) + let operation = RefreshTaskOperation(queueDrained: drained.eraseToAnyPublisher()) + + operation.start() + XCTAssertTrue(operation.isExecuting) + XCTAssertFalse(operation.isFinished) + + drained.send(false) + XCTAssertFalse(operation.isFinished, "a non-drained emission must not end the window") + + drained.send(true) + XCTAssertTrue(operation.isFinished) + XCTAssertFalse(operation.isExecuting) + } + + func testCancelledBeforeStartFinishesWithoutSubscribing() { + let drained = PassthroughSubject() + let operation = RefreshTaskOperation(queueDrained: drained.eraseToAnyPublisher()) + + operation.cancel() + operation.start() + + XCTAssertTrue(operation.isFinished) + } + + /// The expiration handler calls finish() after cancel(); a late emission must not flip + /// the KVO state a second time. + func testFinishIsIdempotent() { + let drained = CurrentValueSubject(false) + let operation = RefreshTaskOperation(queueDrained: drained.eraseToAnyPublisher()) + operation.start() + + var finishedTransitions = 0 + let observer = operation.observe(\.isFinished, options: [.new]) { _, _ in + finishedTransitions += 1 + } + defer { observer.invalidate() } + + operation.cancel() + operation.finish() + operation.finish() + drained.send(true) + + XCTAssertTrue(operation.isFinished) + XCTAssertEqual(finishedTransitions, 1) + } + + /// The policy the AppDelegate wires: a drained sync lane ends the window even while + /// uploads and provider pushes are still queued. + func testSyncLanePolicy_ignoresOtherLanes() { + let counts = CurrentValueSubject( + QueueCounts(byQueueKey: [TaskQueueKey.uploadFile: 3, "jellyfin": 1, TaskQueueKey.sync: 1]) + ) + let operation = RefreshTaskOperation(queueDrained: counts.laneDrained(TaskQueueKey.sync)) + + operation.start() + XCTAssertFalse(operation.isFinished) + + counts.send(QueueCounts(byQueueKey: [TaskQueueKey.uploadFile: 3, "jellyfin": 1])) + XCTAssertTrue(operation.isFinished) + } +} diff --git a/BookPlayerTests/Services/AccountServiceTests.swift b/BookPlayerTests/Services/AccountServiceTests.swift index e10931b15..4c2b3d6da 100644 --- a/BookPlayerTests/Services/AccountServiceTests.swift +++ b/BookPlayerTests/Services/AccountServiceTests.swift @@ -88,3 +88,66 @@ class AccountServiceTests: XCTestCase { XCTAssert(try keychainMock.get(.token) == nil) } } + +// MARK: - Streaming access + +/// Media-server streaming is granted to anyone who has EVER paid, so the rule has to answer +/// correctly for a history rather than for the current subscription. +final class StreamingAccessTests: XCTestCase { + func testAnUnrefundedSubscriptionCounts() { + XCTAssertTrue(AccountService.hasUnrefundedSubscription(refundDates: [nil])) + } + + func testARefundOnlyHistoryDoesNot() { + XCTAssertFalse(AccountService.hasUnrefundedSubscription(refundDates: [Date()])) + } + + /// The reason this reads the whole dictionary instead of `getSubscriptionInfo(from:)`, which + /// breaks on the first `PricingOption` match: a refunded pro alongside a legitimately lapsed + /// lite must still grant access, whichever one the enum happens to list first. + func testARefundAlongsideAGenuineSubscriptionStillCounts() { + XCTAssertTrue(AccountService.hasUnrefundedSubscription(refundDates: [Date(), nil])) + XCTAssertTrue(AccountService.hasUnrefundedSubscription(refundDates: [nil, Date()])) + } + + func testNoPurchaseHistoryDoesNot() { + XCTAssertFalse(AccountService.hasUnrefundedSubscription(refundDates: [])) + } + + // MARK: composing the rule + + func testEitherPaidClauseGrantsAccessWhenSignedIn() { + XCTAssertTrue( + AccountService.resolveStreamingAccess(isSignedIn: true, hasPlusAccess: true, hasEverSubscribed: false) + ) + XCTAssertTrue( + AccountService.resolveStreamingAccess(isSignedIn: true, hasPlusAccess: false, hasEverSubscribed: true) + ) + } + + /// The case that prompted the sign-in clause: `donationMade` deliberately outlives logout, + /// so a past tipper who signs out still satisfies `hasPlusAccess()` — and used to keep + /// streaming with no account at all. + func testPayingIsNotEnoughWhileSignedOut() { + XCTAssertFalse( + AccountService.resolveStreamingAccess(isSignedIn: false, hasPlusAccess: true, hasEverSubscribed: false), + "a tip that survived logout must not reopen streaming" + ) + XCTAssertFalse( + AccountService.resolveStreamingAccess(isSignedIn: false, hasPlusAccess: false, hasEverSubscribed: true) + ) + } + + /// Signing in is necessary, not sufficient — streaming is still something you have to buy. + func testSigningInAloneDeniesAccess() { + XCTAssertFalse( + AccountService.resolveStreamingAccess(isSignedIn: true, hasPlusAccess: false, hasEverSubscribed: false) + ) + } + + func testNothingPaidAndSignedOutDeniesAccess() { + XCTAssertFalse( + AccountService.resolveStreamingAccess(isSignedIn: false, hasPlusAccess: false, hasEverSubscribed: false) + ) + } +} diff --git a/BookPlayerTests/Services/BackgroundSessionWakeTests.swift b/BookPlayerTests/Services/BackgroundSessionWakeTests.swift new file mode 100644 index 000000000..182d53890 --- /dev/null +++ b/BookPlayerTests/Services/BackgroundSessionWakeTests.swift @@ -0,0 +1,175 @@ +// +// BackgroundSessionWakeTests.swift +// BookPlayerTests +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation +import UIKit +import XCTest + +@testable import BookPlayer +@testable import BookPlayerKit + +/// iOS's handler for a background session is called once, and for uploads only after they +/// settle (calling it earlier suspends the app mid-request) +@MainActor +final class BackgroundSessionWakeTests: XCTestCase { + private var settleCalls = 0 + private var downloadSettleCalls = 0 + private var settleRelease: CheckedContinuation? + private var began = 0 + private var ended = 0 + private var coordinator: BackgroundSessionWakeCoordinator! + + private func makeCoordinator( + settles: Bool = true, + timeout: Duration = .seconds(5), + staleEventsAfter: TimeInterval = 60 + ) { + coordinator = BackgroundSessionWakeCoordinator(dependencies: .init( + activateSessions: { _ in }, + settleUploads: { [unowned self] in + self.settleCalls += 1 + guard !settles else { return } + await withCheckedContinuation { self.settleRelease = $0 } + }, + settleDownloads: { [unowned self] in self.downloadSettleCalls += 1 }, + beginBackgroundTask: { [unowned self] _ in + self.began += 1 + return UIBackgroundTaskIdentifier(rawValue: 42) + }, + endBackgroundTask: { [unowned self] _ in self.ended += 1 }, + timeout: timeout, + staleEventsAfter: staleEventsAfter + )) + } + + private func finishEvents(_ identifier: String) { + NotificationCenter.default.post(name: .backgroundSessionFinishedEvents, object: identifier) + } + + private func waitUntil(_ timeout: TimeInterval = 2, _ condition: () -> Bool) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline, !condition() { + try await Task.sleep(for: .milliseconds(10)) + } + } + + override func tearDown() { + settleRelease?.resume() + settleRelease = nil + coordinator = nil + super.tearDown() + } + + func testUploadWake_answersAfterTheUploadsSettle() async throws { + makeCoordinator() + var calls = 0 + coordinator.handleEvents(forSession: BackgroundTransferSessions.uploadIdentifier) { calls += 1 } + XCTAssertEqual(calls, 0, "not before the session's events are in") + + finishEvents(BackgroundTransferSessions.uploadIdentifier) + + try await waitUntil { calls == 1 } + XCTAssertEqual(calls, 1) + XCTAssertEqual(settleCalls, 1) + XCTAssertEqual(began, 1) + XCTAssertEqual(ended, 1, "the background task ends with the wake") + } + + /// The spike saw iOS hand the handler over after the events were already delivered + func testUploadWake_eventsFinishedBeforeTheHandler() async throws { + makeCoordinator() + finishEvents(BackgroundTransferSessions.cellularUploadIdentifier) + var calls = 0 + + coordinator.handleEvents(forSession: BackgroundTransferSessions.cellularUploadIdentifier) { calls += 1 } + + try await waitUntil { calls == 1 } + XCTAssertEqual(calls, 1) + XCTAssertEqual(settleCalls, 1) + } + + /// A stuck upload mustn't keep iOS waiting: the timeout answers, once + func testUploadWake_timesOutWhenTheUploadsNeverSettle() async throws { + makeCoordinator(settles: false, timeout: .milliseconds(100)) + var calls = 0 + coordinator.handleEvents(forSession: BackgroundTransferSessions.uploadIdentifier) { calls += 1 } + finishEvents(BackgroundTransferSessions.uploadIdentifier) + + try await waitUntil { calls == 1 } + settleRelease?.resume() + settleRelease = nil + try await Task.sleep(for: .milliseconds(200)) + + XCTAssertEqual(calls, 1, "settling late doesn't call the handler again") + XCTAssertEqual(ended, 1) + } + + /// The finished downloads' follow-up (chapters, verification, scheduling) runs after their + /// delegate callbacks: the wake waits for it too + func testDownloadWake_answersOnceTheDownloadsSettle() async throws { + makeCoordinator() + var calls = 0 + coordinator.handleEvents(forSession: BackgroundTransferSessions.downloadIdentifier) { calls += 1 } + XCTAssertEqual(calls, 0) + + finishEvents(BackgroundTransferSessions.downloadIdentifier) + + try await waitUntil { calls == 1 } + XCTAssertEqual(calls, 1) + XCTAssertEqual(downloadSettleCalls, 1) + XCTAssertEqual(settleCalls, 0, "downloads have no parts to top up") + XCTAssertEqual(ended, 1) + } + + /// A new wake for the same session answers the one it replaces instead of dropping it + func testSecondWake_forTheSameSession_answersTheFirst() { + makeCoordinator() + var first = 0 + var second = 0 + coordinator.handleEvents(forSession: BackgroundTransferSessions.uploadIdentifier) { first += 1 } + + coordinator.handleEvents(forSession: BackgroundTransferSessions.uploadIdentifier) { second += 1 } + + XCTAssertEqual(first, 1) + XCTAssertEqual(second, 0) + } + + /// Events that finished long before belong to an earlier wake: the new one still waits + func testStaleFinishedEvents_doNotAnswerALaterWake() async throws { + makeCoordinator(timeout: .milliseconds(300), staleEventsAfter: 0.05) + finishEvents(BackgroundTransferSessions.downloadIdentifier) + try await Task.sleep(for: .milliseconds(100)) + var calls = 0 + + coordinator.handleEvents(forSession: BackgroundTransferSessions.downloadIdentifier) { calls += 1 } + + XCTAssertEqual(downloadSettleCalls, 0, "not answered from the stale record") + try await waitUntil { calls == 1 } + XCTAssertEqual(calls, 1, "the fallback still answers") + } + + /// A session nothing resumes in the background (e.g. the single-file downloads) + func testUnknownSession_isAnsweredRightAway() { + makeCoordinator() + var calls = 0 + + coordinator.handleEvents(forSession: "SingleFileDownloadService") { calls += 1 } + + XCTAssertEqual(calls, 1) + } + + /// The session never reports back in this launch: iOS is still answered + func testWake_withoutEvents_isAnsweredByTheFallback() async throws { + makeCoordinator(timeout: .milliseconds(100)) + var calls = 0 + + coordinator.handleEvents(forSession: BackgroundTransferSessions.downloadIdentifier) { calls += 1 } + + try await waitUntil { calls == 1 } + XCTAssertEqual(calls, 1) + } +} diff --git a/BookPlayerTests/Services/IntegrationHostResolverTests.swift b/BookPlayerTests/Services/IntegrationHostResolverTests.swift new file mode 100644 index 000000000..4509af35e --- /dev/null +++ b/BookPlayerTests/Services/IntegrationHostResolverTests.swift @@ -0,0 +1,63 @@ +// +// IntegrationHostResolverTests.swift +// BookPlayerTests +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import XCTest + +@testable import BookPlayerKit + +/// Pins the cross-platform stable-host resolution contract (the Android app ships the same +/// rules in its `ServerResolutionTest`): GUID match is case-insensitive, the URL fallback uses +/// the canonical dedup key, and an unknown host resolves to NOTHING — never to whichever +/// connection happens to exist. +final class IntegrationHostResolverTests: XCTestCase { + private func connection(serverId: String?, url: String, id: String = UUID().uuidString) -> JellyfinConnectionData { + JellyfinConnectionData( + id: id, + serverId: serverId, + url: URL(string: url)!, + serverName: "srv", + userID: "user-1", + userName: "name", + accessToken: "token" + ) + } + + func testMatchesByServerIdCaseInsensitively() { + // Jellyfin reports lowercase hex, ABS uppercase UUIDs — casing must never break the match. + let connections = [connection(serverId: "ABC-DEF-123", url: "https://jf.example.com")] + let hit = IntegrationHostResolver.connection(for: "abc-def-123", in: connections) + XCTAssertEqual(hit?.serverId, "ABC-DEF-123") + } + + func testFallsBackToCanonicalUrlKeyForGuidlessConnections() { + // The other device imported against this server before it reported a GUID: hostId is the + // canonical key. This device saved the same URL with default port + trailing slash. + let connections = [connection(serverId: nil, url: "https://jf.example.com:443/")] + let hit = IntegrationHostResolver.connection( + for: URL(string: "https://jf.example.com")!.canonicalDedupKey, + in: connections + ) + XCTAssertNotNil(hit) + } + + func testUnknownHostResolvesToNothing() { + // The old `?? connections.first` guess streamed the wrong file / pushed progress to the + // wrong server when provider ids collide across instances. + let connections = [connection(serverId: "real-guid", url: "https://jf.example.com")] + XCTAssertNil(IntegrationHostResolver.connection(for: "other-guid", in: connections)) + XCTAssertNil(IntegrationHostResolver.connection(for: nil, in: connections)) + XCTAssertNil(IntegrationHostResolver.connection(for: "", in: connections)) + } + + func testStableHostIdPrefersGuidOverCanonicalKey() { + let withGuid = connection(serverId: "guid-1", url: "https://jf.example.com/") + XCTAssertEqual(withGuid.stableHostId, "guid-1") + + let withoutGuid = connection(serverId: nil, url: "https://jf.example.com/") + XCTAssertEqual(withoutGuid.stableHostId, withoutGuid.url.canonicalDedupKey) + } +} diff --git a/BookPlayerTests/Services/LibraryServiceTests.swift b/BookPlayerTests/Services/LibraryServiceTests.swift index 7d109f574..50476c450 100644 --- a/BookPlayerTests/Services/LibraryServiceTests.swift +++ b/BookPlayerTests/Services/LibraryServiceTests.swift @@ -1337,18 +1337,27 @@ class ModifyLibraryTests: LibraryServiceTests { XCTAssert(fetchedChapters?.last?.index == 2) } - func testGetItemsNotIncluded() async throws { - let emptyResult = await self.sut.getItemsToSync(remoteIdentifiers: []) - XCTAssert(emptyResult?.isEmpty == true) + /// The missing-items pass reads every uuid, then the items the server asked about + func testUuidFetches_readTheWholeLibraryAndRegisterParentsFirst() async throws { + let emptyUuids = await self.sut.fetchAllUuids() + XCTAssertEqual(emptyUuids, []) - _ = try! self.sut.createFolder(with: "test-folder", inside: nil) - _ = try! self.sut.createFolder(with: "test-folder2", inside: nil) + let folder = try self.sut.createFolder(with: "test-folder", inside: nil) + let nested = try self.sut.createFolder(with: "nested", inside: folder.relativePath) + let other = try self.sut.createFolder(with: "test-folder2", inside: nil) - let secondResult = await self.sut.getItemsToSync(remoteIdentifiers: []) - XCTAssert(secondResult?.count == 2) + let uuids = await self.sut.fetchAllUuids() + XCTAssertEqual(Set(uuids ?? []), [folder.uuid, nested.uuid, other.uuid]) - let thirdResult = await self.sut.getItemsToSync(remoteIdentifiers: ["test-folder"]) - XCTAssert(thirdResult?.count == 1) + let items = await self.sut.getSyncableItems(forUuids: [nested.uuid, folder.uuid]) + XCTAssertEqual(items?.map(\.relativePath), ["test-folder", "test-folder/nested"]) + let none = await self.sut.getSyncableItems(forUuids: []) + XCTAssertEqual(none?.isEmpty, true) + + // Read 500 uuids per fetch: items in different chunks all come back + let spread = [folder.uuid] + (0..<600).map { _ in UUID().uuidString } + [other.uuid] + let found = await self.sut.getSyncableItems(forUuids: spread) + XCTAssertEqual(found?.map(\.relativePath), ["test-folder", "test-folder2"]) } func testGetItemProperty() { @@ -2135,3 +2144,417 @@ class RegisterExistingProcessedItemsTests: LibraryServiceTests { XCTAssertEqual(library.items?.count, 1) } } + +/// External-resource service logic: link/dedup, removal, provider-scoped lookup, +/// and the two-way progress sync (add/remove/reconcile paths for the media-server +/// ExternalResource entity added in model v12). +class LibraryServiceExternalResourceTests: XCTestCase { + var sut: LibraryService! + + override func setUp() { + DataTestUtils.clearFolderContents(url: DataManager.getProcessedFolderURL()) + let dataManager = DataManager(coreDataStack: CoreDataStack(testPath: "/dev/null")) + self.sut = LibraryService() + self.sut.setup(dataManager: dataManager, audioMetadataService: AudioMetadataService()) + _ = self.sut.getLibrary() + } + + private func makeBook(_ title: String, duration: Double = 100) -> Book { + let book = StubFactory.book(dataManager: sut.dataManager, title: title, duration: duration) + sut.getLibraryReference().addToItems(book) + sut.dataManager.saveContext() + return book + } + + func testSetExternalResourceLinksAndSkipsDuplicates() async { + let book = makeBook("external-1") + + let linked = await sut.setExternalResource(providerName: "jellyfin", providerId: "prov-1", for: book.uuid) + XCTAssertEqual(linked?.providerId, "prov-1") + + // Linking the same (provider, providerId) again is a no-op + let duplicate = await sut.setExternalResource(providerName: "jellyfin", providerId: "prov-1", for: book.uuid) + XCTAssertNil(duplicate) + + let resources = await sut.getExternalResources(for: book.relativePath) + XCTAssertEqual(resources.count, 1) + XCTAssertEqual(resources.first?.providerName, "jellyfin") + } + + func testSetExternalResourceForUnknownUuidReturnsNil() async { + let missing = await sut.setExternalResource(providerName: "jellyfin", providerId: "prov-x", for: UUID().uuidString) + XCTAssertNil(missing) + } + + func testRemoveExternalResource() async { + let book = makeBook("external-2") + _ = await sut.setExternalResource(providerName: "audiobookshelf", providerId: "abs-1", for: book.uuid) + + let removedId = await sut.removeExternalResource(providerName: "audiobookshelf", for: book.uuid) + XCTAssertEqual(removedId, "abs-1") + + let resources = await sut.getExternalResources(for: book.relativePath) + XCTAssertTrue(resources.isEmpty) + + // Removing again is a no-op + let second = await sut.removeExternalResource(providerName: "audiobookshelf", for: book.uuid) + XCTAssertNil(second) + } + + func testFindResourceIsProviderScoped() async { + let book1 = makeBook("external-3") + let book2 = makeBook("external-4") + // The same providerId under two different providers: cross-instance id collisions + // must resolve to the right item per provider + _ = await sut.setExternalResource(providerName: "jellyfin", providerId: "shared-id", for: book1.uuid) + _ = await sut.setExternalResource(providerName: "audiobookshelf", providerId: "shared-id", for: book2.uuid) + + let jellyfin = sut.findResource(for: "shared-id", providerName: "jellyfin") + XCTAssertEqual(jellyfin?.libraryItemUuid, book1.uuid) + + let abs = sut.findResource(for: "shared-id", providerName: "audiobookshelf") + XCTAssertEqual(abs?.libraryItemUuid, book2.uuid) + } + + // MARK: - Media-server chapters + + private func chapters(_ spans: [(TimeInterval, TimeInterval)]) -> [ChapterMetadata] { + spans.enumerated().map { index, span in + ChapterMetadata(title: "Chapter \(index + 1)", start: span.0, duration: span.1, index: index + 1) + } + } + + func testStoreChaptersIfNeededFillsABookWithNone() async { + let book = makeBook("external-chapters-1", duration: 1500) + + await sut.storeChaptersIfNeeded(relativePath: book.relativePath, chapters: chapters([(0, 600), (600, 900)])) + + sut.dataManager.getContext().refresh(book, mergeChanges: true) + XCTAssertEqual(sut.getChapters(from: book.relativePath)?.map(\.title), ["Chapter 1", "Chapter 2"]) + } + + func testStoreChaptersIfNeededNeverReplacesAList() async { + let book = makeBook("external-chapters-2", duration: 1500) + await sut.storeChaptersIfNeeded(relativePath: book.relativePath, chapters: chapters([(0, 1500)])) + + await sut.storeChaptersIfNeeded(relativePath: book.relativePath, chapters: chapters([(0, 600), (600, 900)])) + + sut.dataManager.getContext().refresh(book, mergeChanges: true) + XCTAssertEqual( + sut.getChapters(from: book.relativePath)?.count, + 1, + "a parsed list on a downloaded copy must not be overwritten by a later server refresh" + ) + } + + func testStoreChaptersIfNeededNeverClearsAListWithAnEmptyAnswer() async { + let book = makeBook("external-chapters-3", duration: 1500) + await sut.storeChaptersIfNeeded(relativePath: book.relativePath, chapters: chapters([(0, 600), (600, 900)])) + + // A server that reports no chapters must not wipe what the item already has. + await sut.storeChaptersIfNeeded(relativePath: book.relativePath, chapters: []) + + sut.dataManager.getContext().refresh(book, mergeChanges: true) + XCTAssertEqual(sut.getChapters(from: book.relativePath)?.count, 2) + } + + func testFindMediaServerResourcesMarksOnlyTheBooksWithoutChapters() async { + let chaptered = makeBook("needs-nothing", duration: 1500) + let bare = makeBook("needs-chapters", duration: 1500) + _ = await sut.setExternalResource(providerName: "jellyfin", providerId: "jf-chaptered", for: chaptered.uuid) + _ = await sut.setExternalResource(providerName: "jellyfin", providerId: "jf-bare", for: bare.uuid) + await sut.storeChaptersIfNeeded(relativePath: chaptered.relativePath, chapters: chapters([(0, 1500)])) + + let resources = await sut.findMediaServerResources(at: nil) + + let byProviderId = Dictionary(uniqueKeysWithValues: resources.map { ($0.providerId, $0.needsChapters) }) + XCTAssertEqual(byProviderId["jf-bare"], true) + XCTAssertEqual( + byProviderId["jf-chaptered"], + false, + "a book that already has chapters must not make the refresh ask for them again" + ) + } + + @MainActor + func testHandleSyncFromExternalResourceStoresTheSnapshotsChapters() async { + let book = makeBook("external-chapters-4", duration: 1500) + _ = await sut.setExternalResource(providerName: "jellyfin", providerId: "jelly-ch", for: book.uuid) + sut.dataManager.getContext().refreshAllObjects() + + sut.handleSyncFromExternalResource( + providerName: "jellyfin", + snapshotsByProviderId: [ + "jelly-ch": ExternalItemSnapshot( + progress: ExternalPlaybackProgress(currentTime: 0, lastPlayedDate: nil), + chapters: chapters([(0, 600), (600, 900)]) + ) + ] + ) + + sut.dataManager.getContext().refresh(book, mergeChanges: true) + XCTAssertEqual( + sut.getChapters(from: book.relativePath)?.map(\.title), + ["Chapter 1", "Chapter 2"], + "chapters ride the same response and land in the same save as the progress" + ) + } + + @MainActor + func testHandleSyncFromExternalResourceUpdatesNewerProgress() async { + let book = makeBook("external-5", duration: 200) + // Background write FIRST, then refresh before the view-context mutations: there is + // no merge policy (conflicts fatalError by design), so the test must not hold a + // stale view snapshot across the background save. + _ = await sut.setExternalResource(providerName: "jellyfin", providerId: "jelly-5", for: book.uuid) + sut.dataManager.getContext().refreshAllObjects() + + book.currentTime = 10 + book.lastPlayDate = Date(timeIntervalSince1970: 1_000) + sut.dataManager.saveContext() + + let remoteDate = Date(timeIntervalSince1970: 2_000) + let remote = ExternalPlaybackProgress(currentTime: 100, lastPlayedDate: remoteDate, isFinished: false) + sut.handleSyncFromExternalResource( + providerName: "jellyfin", + snapshotsByProviderId: ["jelly-5": ExternalItemSnapshot(progress: remote)] + ) + + sut.dataManager.getContext().refresh(book, mergeChanges: true) + XCTAssertEqual(book.currentTime, 100, accuracy: 0.01) + XCTAssertEqual(book.lastPlayDate, remoteDate) + // The library row renders percentCompleted — it must move with the playhead + XCTAssertEqual(book.percentCompleted, 50, accuracy: 0.01) + } + + @MainActor + func testHandleSyncFromExternalResourceIgnoresOlderProgress() async { + let localDate = Date(timeIntervalSince1970: 5_000) + let book = makeBook("external-6", duration: 200) + // Same ordering rule as the newer-progress test: background write first + _ = await sut.setExternalResource(providerName: "jellyfin", providerId: "jelly-6", for: book.uuid) + sut.dataManager.getContext().refreshAllObjects() + + book.currentTime = 150 + book.lastPlayDate = localDate + sut.dataManager.saveContext() + + let remote = ExternalPlaybackProgress( + currentTime: 20, + lastPlayedDate: Date(timeIntervalSince1970: 1_000), + isFinished: false + ) + sut.handleSyncFromExternalResource( + providerName: "jellyfin", + snapshotsByProviderId: ["jelly-6": ExternalItemSnapshot(progress: remote)] + ) + + sut.dataManager.getContext().refresh(book, mergeChanges: true) + // Local progress is newer — the stale remote push must not clobber it + XCTAssertEqual(book.currentTime, 150, accuracy: 0.01) + XCTAssertEqual(book.lastPlayDate, localDate) + } + + /// The predicate now filters on the provider name it was handed, so one provider's answers + /// can never land on another provider's rows — a provider id only means something to the + /// server that issued it. + @MainActor + func testHandleSyncFromExternalResourceOnlyTouchesTheNamedProvider() async { + let book = makeBook("external-7", duration: 200) + _ = await sut.setExternalResource(providerName: "jellyfin", providerId: "shared-id", for: book.uuid) + sut.dataManager.getContext().refreshAllObjects() + + book.currentTime = 10 + book.lastPlayDate = Date(timeIntervalSince1970: 1_000) + sut.dataManager.saveContext() + + // Same providerId, different provider: an ABS batch must not move the Jellyfin-linked row. + sut.handleSyncFromExternalResource( + providerName: "audiobookshelf", + snapshotsByProviderId: [ + "shared-id": ExternalItemSnapshot( + progress: ExternalPlaybackProgress( + currentTime: 180, + lastPlayedDate: Date(timeIntervalSince1970: 9_000) + ) + ) + ] + ) + + sut.dataManager.getContext().refresh(book, mergeChanges: true) + XCTAssertEqual(book.currentTime, 10, accuracy: 0.01, "another provider's answer is not ours") + } + + @MainActor + func testInsertItemsFromResourcesCreatesExternalBooks() async { + let simpleItem = SimpleLibraryItem( + title: "remote-book", + details: "remote-author", + speed: 1.0, + currentTime: 0, + duration: 300, + percentCompleted: 0, + isFinished: false, + relativePath: "jellyfin/remote-book.m4b", + remoteURL: nil, + artworkURL: nil, + orderRank: 0, + parentFolder: nil, + originalFileName: "remote-book.m4b", + lastPlayDate: nil, + type: .book, + uuid: UUID().uuidString + ) + let resource = SimpleExternalResource( + providerName: "jellyfin", + providerId: "insert-1", + syncStatus: ExternalResource.SyncStatus.notSynced.rawValue, + lastSyncedAt: nil, + libraryItem: simpleItem + ) + // A resource without a libraryItem cannot become a book row and must be skipped + let orphan = SimpleExternalResource( + providerName: "jellyfin", + providerId: "insert-orphan", + syncStatus: ExternalResource.SyncStatus.notSynced.rawValue, + lastSyncedAt: nil + ) + + let inserted = await sut.insertItems(fromResources: [resource, orphan]) + + XCTAssertEqual(inserted.count, 1) + XCTAssertEqual(inserted.first?.title, "remote-book") + + // createExternalBook deliberately mints a fresh local uuid (matchUuid reconciles + // against the server later) and provider-scopes relativePath so same-named books + // from different servers can't collide + XCTAssertEqual(inserted.first?.relativePath, "insert-1-remote-book.m4b") + let stored = sut.findResource(for: "insert-1", providerName: "jellyfin") + XCTAssertEqual(stored?.libraryItemUuid, inserted.first?.uuid) + XCTAssertNil(sut.findResource(for: "insert-orphan", providerName: "jellyfin")) + } + + /// Re-importing the same provider identity must reuse the existing row: inserting + /// again would create a twin Book sharing one relativePath — the library's de-facto + /// primary key, which has no store-level uniqueness constraint. + @MainActor + func testInsertItemsIsIdempotentOnReimport() async { + let simpleItem = SimpleLibraryItem( + title: "twin-book", + details: "author", + speed: 1.0, + currentTime: 0, + duration: 300, + percentCompleted: 0, + isFinished: false, + relativePath: "jellyfin/twin-book.m4b", + remoteURL: nil, + artworkURL: nil, + orderRank: 0, + parentFolder: nil, + originalFileName: "twin-book.m4b", + lastPlayDate: nil, + type: .book, + uuid: UUID().uuidString + ) + let resource = SimpleExternalResource( + providerName: "jellyfin", + providerId: "twin-1", + syncStatus: ExternalResource.SyncStatus.notSynced.rawValue, + lastSyncedAt: nil, + libraryItem: simpleItem + ) + + let first = await sut.insertItems(fromResources: [resource]) + XCTAssertEqual(first.count, 1) + + let second = await sut.insertItems(fromResources: [resource]) + XCTAssertTrue(second.isEmpty, "re-import must be skipped, not create a twin row") + + let rowsAtPath = sut.fetchIdentifiers().filter { $0 == "twin-1-twin-book.m4b" } + XCTAssertEqual(rowsAtPath.count, 1, "exactly one row may exist at the synthesized relativePath") + } + + // MARK: - findMediaServerResources(at:) — the level query behind the list refresh + + /// Root level: every media-server link on a root item, Hardcover filtered out in the query. + func testFindMediaServerResourcesAtRootSkipsHardcover() async { + let first = makeBook("root-a") + let second = makeBook("root-b") + _ = await sut.setExternalResource(providerName: "jellyfin", providerId: "jf-1", for: first.uuid) + _ = await sut.setExternalResource(providerName: "hardcover", providerId: "hc-1", for: first.uuid) + _ = await sut.setExternalResource(providerName: "audiobookshelf", providerId: "abs-1", for: second.uuid) + + let resources = await sut.findMediaServerResources(at: nil) + + XCTAssertEqual(Set(resources.map(\.providerId)), ["jf-1", "abs-1"]) + XCTAssertFalse(resources.contains { $0.providerName == "hardcover" }) + } + + /// A level is its direct children only, like the list: a book moved into a folder leaves the + /// root result and appears in the folder's, and the folder row itself has no links. + func testFindMediaServerResourcesAtFolderReturnsDirectChildrenOnly() async throws { + let inFolder = makeBook("folder-book") + let atRoot = makeBook("root-book") + // Structure first, on the view context; the links after, on the background context. + // No merge policy is set anywhere, so a view-context save over a row the background + // context just changed is a hard crash, not a conflict to resolve (CLAUDE.md hotlist #1). + let folder = try sut.createFolder(with: "Series", inside: nil) + try sut.moveItems([LibraryItemRef(relativePath: inFolder.relativePath, uuid: inFolder.uuid)], inside: folder.relativePath) + _ = await sut.setExternalResource(providerName: "jellyfin", providerId: "in-folder", for: inFolder.uuid) + _ = await sut.setExternalResource(providerName: "jellyfin", providerId: "at-root", for: atRoot.uuid) + + let folderResources = await sut.findMediaServerResources(at: folder.relativePath) + let rootResources = await sut.findMediaServerResources(at: nil) + + XCTAssertEqual(folderResources.map(\.providerId), ["in-folder"]) + XCTAssertEqual(rootResources.map(\.providerId), ["at-root"], "the moved book no longer belongs to root") + } + + func testFindMediaServerResourcesAtEmptyLevelIsEmpty() async { + _ = makeBook("unlinked") + + let resources = await sut.findMediaServerResources(at: nil) + let missingFolder = await sut.findMediaServerResources(at: "Nowhere") + + XCTAssertTrue(resources.isEmpty) + XCTAssertTrue(missingFolder.isEmpty) + } +} + +// MARK: - Hardcover stub repair + +@MainActor +final class SimpleHardcoverBookRepairTests: XCTestCase { + /// The merge rule behind the device-B stub repair: metadata comes from the fetched + /// copy, monotonic state stays local. getBook(id:) always returns status .local and + /// no userBookID (metadata-only query), so taking state from the fetch would regress + /// reading progress already pushed to Hardcover and break the unlink flow's + /// userBookID check. + func testRepairKeepsLocalMonotonicStateAndTakesFetchedMetadata() { + let stub = SimpleHardcoverBook( + id: 445742, + artworkURL: nil, + title: "", + author: "", + status: .reading, + userBookID: 99 + ) + let fetched = SimpleHardcoverBook( + id: 445742, + artworkURL: URL(string: "https://assets.hardcover.app/books/445742/cover.jpg"), + title: "Awaken Online: Catharsis", + author: "Travis Bagwell", + status: .local, + userBookID: nil + ) + + let repaired = stub.repairingMetadata(from: fetched) + + XCTAssertEqual(repaired.title, "Awaken Online: Catharsis") + XCTAssertEqual(repaired.author, "Travis Bagwell") + XCTAssertNotNil(repaired.artworkURL) + XCTAssertEqual(repaired.status, .reading, "repair must not regress the pushed reading status") + XCTAssertEqual(repaired.userBookID, 99, "losing userBookID would break the unlink removal call") + } +} diff --git a/BookPlayerTests/Services/MissingItemsPassTests.swift b/BookPlayerTests/Services/MissingItemsPassTests.swift new file mode 100644 index 000000000..50cac5a92 --- /dev/null +++ b/BookPlayerTests/Services/MissingItemsPassTests.swift @@ -0,0 +1,623 @@ +// +// MissingItemsPassTests.swift +// BookPlayerTests +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation +import SwiftData +import XCTest + +@testable import BookPlayer +@testable import BookPlayerKit + +/// The missing-items pass (bookplayer-api docs/multipart-uploads.md): the first sync's +/// registration step, then again on LITE → PRO and weekly. It works by uuid, never by path, +/// so a path that went stale on this device can't move anything back on the server. +final class MissingItemsPassTests: XCTestCase { + /// Answers `/status` from its script and records every request + private final class StatusClient: NetworkClientMock, @unchecked Sendable { + private let lock = NSLock() + private var _statusRequests = [[String]]() + private var _matchRequests = [[String: String]]() + private var _answer: (unknown: [String], unsynced: [String]) = ([], []) + private var _conflicts = [[String: String]]() + private var _failsStatus = false + private var _rootContent = [[String: Any]]() + private var _onStatus: (() async -> Void)? + + var statusRequests: [[String]] { lock.withLock { _statusRequests } } + var matchRequests: [[String: String]] { lock.withLock { _matchRequests } } + + /// `/uuids` answers these conflicts: local uuid → the server's + func conflict(_ localUuid: String, serverUuid: String) { + lock.withLock { _conflicts.append(["key": localUuid, "uuid": serverUuid]) } + } + + init() { + super.init(mockedResponse: Empty()) + } + + func answer(unknown: [String] = [], unsynced: [String] = []) { + lock.withLock { _answer = (unknown, unsynced) } + } + + func failStatus() { + lock.withLock { _failsStatus = true } + } + + func setRootContent(_ content: [[String: Any]]) { + lock.withLock { _rootContent = content } + } + + /// Runs while the server "answers" `/status` + func onStatus(_ action: @escaping () async -> Void) { + lock.withLock { _onStatus = action } + } + + override func request( + path: String, + method: HTTPMethod, + parameters: [String: Any]? + ) async throws -> T { + if path == "/v1/library/status", let action = lock.withLock({ _onStatus }) { + await action() + } + let object: Any = try lock.withLock { + switch (path, method) { + case ("/v1/library/status", _): + _statusRequests.append(parameters?["uuids"] as? [String] ?? []) + if _failsStatus { throw URLError(.notConnectedToInternet) } + return ["unknown": _answer.unknown, "unsynced": _answer.unsynced] + case ("/v1/library/uuids", _): + _matchRequests.append(parameters?["items"] as? [String: String] ?? [:]) + return ["applied": [String](), "conflicts": _conflicts] + case ("/v1/library", .get): + return ["content": _rootContent] + default: + return [String: Any]() + } + } + return try JSONDecoder().decode(T.self, from: JSONSerialization.data(withJSONObject: object)) + } + } + + /// Its own suite: the test host's SyncService works on `.standard` + private var defaults: UserDefaults! + private var defaultsSuite = "" + + private var tasksDataManager: TasksDataManager! + private var repository: SyncQueueRepository! + private var dataManager: DataManager! + private var libraryService: LibraryService! + private var queue: SyncQueueService! + private var client: StatusClient! + private var account: AccountServiceMock! + private var sync: SyncService! + + override func setUpWithError() throws { + defaultsSuite = "MissingItemsPassTests-\(UUID().uuidString)" + defaults = UserDefaults(suiteName: defaultsSuite) + + let schema = Schema([ + UploadTaskModel.self, + UpdateTaskModel.self, + MoveTaskModel.self, + DeleteTaskModel.self, + DeleteBookmarkTaskModel.self, + SetBookmarkTaskModel.self, + RenameFolderTaskModel.self, + ArtworkUploadTaskModel.self, + MatchUuidsTaskModel.self, + UploadExternalResourceTaskModel.self, + ExternalResourceToDownloadTaskModel.self, + DeleteExternalResourceTaskModel.self, + SyncQueueContainer.self, + QueuedTaskReferenceModel.self, + ExternalUpdateTaskModel.self, + UploadFileTaskModel.self, + ]) + let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none) + tasksDataManager = TasksDataManager(container: try ModelContainer(for: schema, configurations: config)) + repository = SyncQueueRepository(tasksDataManager: tasksDataManager) + + DataTestUtils.clearFolderContents(url: DataManager.getProcessedFolderURL()) + dataManager = DataManager(coreDataStack: CoreDataStack(testPath: "/dev/null")) + libraryService = LibraryService() + libraryService.setup(dataManager: dataManager, audioMetadataService: AudioMetadataService()) + _ = libraryService.getLibrary() + + client = StatusClient() + queue = SyncQueueService(maxConcurrentTasks: 1) + queue.taskContainer = repository + queue.tasksDataManager = tasksDataManager + // Conflicts rewrite the library's uuids through it + queue.dataManager = dataManager + queue.libraryService = libraryService + queue.accessPolicy = [.uploadFile: true, .externalUpdate: true] + queue.networkClient = client + account = AccountServiceMock(account: nil) + sync = makeSyncService(runsMissingItemsPass: true) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: defaultsSuite) + DataTestUtils.clearFolderContents(url: DataManager.getProcessedFolderURL()) + sync = nil + queue = nil + repository = nil + tasksDataManager = nil + super.tearDown() + } + + private func makeSyncService(runsMissingItemsPass: Bool) -> SyncService { + let service = SyncService() + service.setup( + isActive: true, + libraryService: libraryService, + accountService: account, + syncQueueService: queue, + client: client, + runsMissingItemsPass: runsMissingItemsPass, + userDefaults: defaults + ) + // Hold what the pass queues, for the assertions + queue.setServerLanesEnabled(false) + return service + } + + /// A book at the library root, its file in Processed + private func book(_ title: String) -> Book { + let book = StubFactory.book(dataManager: dataManager, title: title, duration: 100) + dataManager.saveContext() + return book + } + + private func queueUploadFile(for book: Book, parked: Bool = false) async throws { + let id = UUID().uuidString + try await repository.storeTask(parameters: [ + "id": id, + "jobType": SyncJobType.uploadFile.rawValue, + "queueKey": TaskQueueKey.uploadFile, + "uuid": book.uuid, + "relativePath": book.relativePath, + "filePath": DataManager.getProcessedFolderURL().appendingPathComponent(book.relativePath).absoluteString, + ]) + if parked { + await repository.park( + taskId: id, + pause: TaskPause(scope: .task, errorCode: "invalid_parts", message: "m", httpStatus: 422, pausedAt: Date()) + ) + } + } + + private func syncLane() async -> [SyncTask] { + await repository.getAllTasksWithParams(in: TaskQueueKey.sync) + } + + private func uploadLane() async -> [SyncTask] { + await repository.getAllTasksWithParams(in: TaskQueueKey.uploadFile) + } + + private func waitUntil(timeout: TimeInterval = 3, _ condition: () async -> Bool) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await condition() { return } + try await Task.sleep(for: .milliseconds(20)) + } + XCTFail("timed out") + } +} + +// MARK: - The pass + +extension MissingItemsPassTests { + /// Items the server never saw are matched by path first (a legacy row at the same path + /// would otherwise keep the server's uuid), then registered like an import, parents first + func testPass_matchesThenRegistersUnknownItems_parentsFirst() async throws { + let alpha = book("Alpha") + let shelf = try libraryService.createFolder(with: "Shelf", inside: nil) + let inner = try libraryService.createFolder(with: "Inner", inside: shelf.relativePath) + client.answer(unknown: [inner.uuid, alpha.uuid, shelf.uuid]) + + try await sync.runMissingItemsPass(startsContinuedUploads: false) + + XCTAssertEqual( + client.matchRequests, + [[alpha.relativePath: alpha.uuid, "Shelf": shelf.uuid, "Shelf/Inner": inner.uuid]] + ) + let lane = await syncLane() + XCTAssertEqual(lane.map(\.jobType), [.upload, .upload, .upload]) + XCTAssertEqual(lane.map(\.relativePath), [alpha.relativePath, "Shelf", "Shelf/Inner"]) + let sent = try XCTUnwrap(client.statusRequests.first) + XCTAssertEqual(Set(sent), [alpha.uuid, shelf.uuid, inner.uuid]) + } + + /// The same file already on the server under another uuid (imported on another device): + /// this device adopts it BEFORE registering, so every task carries the uuid the server knows + func testPass_adoptsTheServersUuidBeforeRegistering() async throws { + let twin = book("Twin") + let localUuid = twin.uuid + let serverUuid = UUID().uuidString + client.answer(unknown: [localUuid]) + client.conflict(localUuid, serverUuid: serverUuid) + + try await sync.runMissingItemsPass(startsContinuedUploads: false) + + let lane = await syncLane() + XCTAssertEqual(lane.map(\.jobType), [.upload]) + XCTAssertEqual(lane.first?.uuid, serverUuid) + XCTAssertEqual(lane.first?.parameters["uuid"] as? String, serverUuid) + let adopted = await libraryService.fetchRelativePath(forUuid: serverUuid) + XCTAssertEqual(adopted, twin.relativePath) + } + + /// Two rows sharing a uuid bring its conflict back twice: the first answer is adopted + func testPass_adoptsTheFirstAnswerForARepeatedConflict() async throws { + let twin = book("Repeated") + let localUuid = twin.uuid + let first = UUID().uuidString + client.answer(unknown: [localUuid]) + client.conflict(localUuid, serverUuid: first) + client.conflict(localUuid, serverUuid: UUID().uuidString) + + try await sync.runMissingItemsPass(startsContinuedUploads: false) + + let lane = await syncLane() + XCTAssertEqual(lane.map(\.uuid), [first]) + let adopted = await libraryService.fetchRelativePath(forUuid: first) + XCTAssertEqual(adopted, twin.relativePath) + } + + /// A registered book brings its user bookmarks along, in time order; other kinds stay local + @MainActor + func testPass_registersUserBookmarksWithTheirBook() async throws { + let marked = book("Marked") + _ = libraryService.createBookmark(at: 200, relativePath: marked.relativePath, uuid: marked.uuid, type: .user) + _ = libraryService.createBookmark(at: 50, relativePath: marked.relativePath, uuid: marked.uuid, type: .user) + _ = libraryService.createBookmark(at: 120, relativePath: marked.relativePath, uuid: marked.uuid, type: .sleep) + client.answer(unknown: [marked.uuid]) + + try await sync.runMissingItemsPass(startsContinuedUploads: false) + + let lane = await syncLane() + XCTAssertEqual(lane.map(\.jobType), [.upload, .setBookmark, .setBookmark]) + XCTAssertEqual(lane.dropFirst().compactMap { $0.parameters["time"] as? Double }, [50, 200]) + XCTAssertEqual(Set(lane.map(\.uuid)), [marked.uuid]) + + // Read 500 paths per fetch: a book past the first batch still brings its bookmarks + let markedPath: String = marked.relativePath + let bookmarks = await libraryService.getUserBookmarks(forItemsAt: (0..<600).map { "missing-\($0)" } + [markedPath]) + XCTAssertEqual(bookmarks[markedPath]?.map { $0.time }, [50, 200]) + XCTAssertEqual(bookmarks.count, 1) + } + + /// Books the server holds without a file go straight to the upload lane by uuid, never + /// through a re-registration (a `PUT /` at a stale path would move them back) + func testPass_queuesFilesOfUnsyncedBooksByUuid_skippingOnesAlreadyQueued() async throws { + let waiting = book("Waiting") + let queued = book("Queued") + try await queueUploadFile(for: queued, parked: true) + client.answer(unsynced: [waiting.uuid, queued.uuid]) + + try await sync.runMissingItemsPass(startsContinuedUploads: false) + + let syncTasks = await syncLane() + XCTAssertTrue(syncTasks.isEmpty) + let uploads = await uploadLane() + XCTAssertEqual(uploads.map(\.uuid).sorted(), [queued.uuid, waiting.uuid].sorted()) + let new = try XCTUnwrap(uploads.first { $0.uuid == waiting.uuid }) + XCTAssertEqual(new.relativePath, waiting.relativePath) + XCTAssertEqual( + (new.parameters["filePath"] as? String).flatMap(URL.init(string:))?.path, + DataManager.getProcessedFolderURL().appendingPathComponent(waiting.relativePath).path + ) + } + + /// Without S3 access (LITE) there are no files to send: only unknown items are registered + func testPass_withoutS3Access_onlyRegisters() async throws { + queue.accessPolicy = [.uploadFile: false, .externalUpdate: true] + let fresh = book("Fresh") + let unsynced = book("Unsynced") + client.answer(unknown: [fresh.uuid], unsynced: [unsynced.uuid]) + + try await sync.runMissingItemsPass(startsContinuedUploads: false) + + let lane = await syncLane() + XCTAssertEqual(lane.map(\.jobType), [.upload]) + let uploads = await uploadLane() + XCTAssertTrue(uploads.isEmpty) + } + + /// An item whose upload is queued (here parked) isn't lost: the pass leaves it to that task + func testPass_leavesAnUnknownItemWithAQueuedUploadAlone() async throws { + let parked = book("Parked") + try await queueUploadFile(for: parked, parked: true) + client.answer(unknown: [parked.uuid]) + + try await sync.runMissingItemsPass(startsContinuedUploads: false) + + let lane = await syncLane() + XCTAssertTrue(lane.isEmpty) + XCTAssertTrue(client.matchRequests.isEmpty) + } + + /// A failed read registers nothing and isn't recorded as a run: it's asked again + func testPass_failedStatus_changesNothing() async throws { + _ = book("Anything") + client.failStatus() + + do { + try await sync.runMissingItemsPass(startsContinuedUploads: true) + XCTFail("expected the pass to fail") + } catch {} + + let lane = await syncLane() + XCTAssertTrue(lane.isEmpty) + XCTAssertEqual(defaults.double(forKey: Constants.UserDefaults.missingItemsPassLastRun), 0) + } + + /// Only the first sync and a tier change start the continued task; the weekly run never does + func testPass_announcesQueuedUploadsOnlyWhenAsked() async throws { + let first = book("First") + client.answer(unsynced: [first.uuid]) + let quiet = expectation(forNotification: .bookUploadsQueued, object: nil) + quiet.isInverted = true + try await sync.runMissingItemsPass(startsContinuedUploads: false) + await fulfillment(of: [quiet], timeout: 0.3) + + let second = book("Second") + client.answer(unsynced: [second.uuid]) + let announced = expectation(forNotification: .bookUploadsQueued, object: nil) + try await sync.runMissingItemsPass(startsContinuedUploads: true) + await fulfillment(of: [announced], timeout: 1) + } + + /// What may go up by uuid: a book, not streamed from a media server, its file on this + /// device, within the 10 GiB ceiling + func testMissingFileUpload_takesOnlyBooksThatCanUpload() throws { + func item(_ path: String, type: SimpleItemType = .book, mediaServer: Bool = false) throws -> SyncableItem { + let resources = mediaServer + ? #","externalResources":[{"providerName":"jellyfin","providerId":"jf-1","syncStatus":"stream"}]"# + : "" + let json = #"{"relativePath":"\#(path)","originalFileName":"\#(path)","title":"t","isFinished":false,"type":\#(type.rawValue),"uuid":"u-\#(path)"\#(resources)}"# + return try JSONDecoder().decode(SyncableItem.self, from: Data(json.utf8)) + } + let processed = DataManager.getProcessedFolderURL() + try FileManager.default.createDirectory(at: processed, withIntermediateDirectories: true) + for name in ["ok.m4b", "stream.m4b", "huge.m4b"] { + FileManager.default.createFile(atPath: processed.appendingPathComponent(name).path, contents: Data("x".utf8)) + } + // Sparse: past the ceiling without writing 10 GiB + let huge = try FileHandle(forWritingTo: processed.appendingPathComponent("huge.m4b")) + try huge.truncate(atOffset: UInt64(FileUploadOperation.maxFileSize) + 1) + try huge.close() + + XCTAssertEqual( + SyncService.missingFileUpload(for: try item("ok.m4b")), + MissingFileUpload(uuid: "u-ok.m4b", relativePath: "ok.m4b", fileURL: processed.appendingPathComponent("ok.m4b")) + ) + XCTAssertNil(SyncService.missingFileUpload(for: try item("stream.m4b", mediaServer: true))) + XCTAssertNil(SyncService.missingFileUpload(for: try item("missing.m4b"))) + XCTAssertNil(SyncService.missingFileUpload(for: try item("huge.m4b"))) + XCTAssertNil(SyncService.missingFileUpload(for: try item("ok.m4b", type: .folder))) + } +} + +// MARK: - When it runs + +extension MissingItemsPassTests { + private func markFirstSyncDone(lastRun: Date? = nil) { + defaults.set(true, forKey: Constants.UserDefaults.hasScheduledLibraryContents) + if let lastRun { + defaults.set(lastRun.timeIntervalSince1970, forKey: Constants.UserDefaults.missingItemsPassLastRun) + } + } + + func testSchedule_runsOnceAWeek() async throws { + markFirstSyncDone(lastRun: Date().addingTimeInterval(-24 * 60 * 60)) + await sync.scheduleMissingItemsIfNeeded() + XCTAssertTrue(client.statusRequests.isEmpty, "a day after the last run") + + markFirstSyncDone(lastRun: Date().addingTimeInterval(-8 * 24 * 60 * 60)) + await sync.scheduleMissingItemsIfNeeded() + XCTAssertEqual(client.statusRequests.count, 1) + XCTAssertGreaterThan( + defaults.double(forKey: Constants.UserDefaults.missingItemsPassLastRun), + Date().addingTimeInterval(-60).timeIntervalSince1970 + ) + } + + /// Before the first sync, that sync is the pass; with changes queued, the server hasn't + /// seen them yet (a queued import would come back unknown and be registered twice) + func testSchedule_waitsForTheFirstSyncAndAnEmptySyncLane() async throws { + await sync.scheduleMissingItemsIfNeeded() + XCTAssertTrue(client.statusRequests.isEmpty, "before the first sync") + + markFirstSyncDone() + let pending = book("Pending") + await sync.jobManager.scheduleSetBookmarkJob(with: pending.relativePath, time: 10, note: nil, for: pending.uuid) + await sync.scheduleMissingItemsIfNeeded() + XCTAssertTrue(client.statusRequests.isEmpty, "with the sync lane busy") + } + + /// The watch has downloaded files of its own: it never runs the pass + func testSchedule_neverRunsWhereThePassIsOff() async throws { + let watchSync = makeSyncService(runsMissingItemsPass: false) + markFirstSyncDone() + + await watchSync.scheduleMissingItemsIfNeeded() + + XCTAssertTrue(client.statusRequests.isEmpty) + } + + /// LITE → PRO: the books registered without their file can now upload, at once, and the + /// continued task starts for them + func testBecomingPro_runsThePassAtOnce_andStartsTheContinuedTask() async throws { + markFirstSyncDone(lastRun: Date()) + defaults.set(false, forKey: Constants.UserDefaults.lastKnownProAccess) + let lite = book("Registered on LITE") + client.answer(unsynced: [lite.uuid]) + account.accessLevelValue = .pro + let announced = expectation(forNotification: .bookUploadsQueued, object: nil) + + sync.noteProAccess() + XCTAssertTrue(defaults.bool(forKey: Constants.UserDefaults.lastKnownProAccess)) + + await fulfillment(of: [announced], timeout: 3) + try await waitUntil { !defaults.bool(forKey: Constants.UserDefaults.missingItemsPassPending) } + XCTAssertEqual(client.statusRequests.count, 1) + let uploads = await uploadLane() + XCTAssertEqual(uploads.map(\.uuid), [lite.uuid]) + } + + /// The first reading (an app update, a fresh install, a sign-in) is no tier change: an + /// update mustn't start the continued task for every PRO user + func testFirstTierReading_isNoTierChange() { + account.accessLevelValue = .pro + + sync.noteProAccess() + + XCTAssertTrue(defaults.bool(forKey: Constants.UserDefaults.lastKnownProAccess)) + XCTAssertFalse(defaults.bool(forKey: Constants.UserDefaults.missingItemsPassPending)) + } + + /// A pending tier change stays pending until the queue has its PRO policy (the account + /// update reaches it on its own hop): only then could its files be queued + func testPendingPass_staysPendingUntilFilesCanBeQueued() async throws { + markFirstSyncDone(lastRun: Date()) + defaults.set(true, forKey: Constants.UserDefaults.missingItemsPassPending) + queue.accessPolicy = [.uploadFile: false, .externalUpdate: true] + + await sync.scheduleMissingItemsIfNeeded() + + XCTAssertEqual(client.statusRequests.count, 1) + XCTAssertTrue(defaults.bool(forKey: Constants.UserDefaults.missingItemsPassPending)) + } + + /// Leaving PRO before the pass ran drops it: LITE has no files to send + func testLeavingPro_dropsAPendingPass() { + defaults.set(true, forKey: Constants.UserDefaults.missingItemsPassPending) + defaults.set(true, forKey: Constants.UserDefaults.lastKnownProAccess) + account.accessLevelValue = .lite + + sync.noteProAccess() + + XCTAssertFalse(defaults.bool(forKey: Constants.UserDefaults.missingItemsPassPending)) + XCTAssertFalse(defaults.bool(forKey: Constants.UserDefaults.lastKnownProAccess)) + } + + /// An account row with this id: blank is how a signed-out account stays behind + private func accountRow(id: String) -> Account { + let row = Account(context: dataManager.getContext()) + row.id = id + return row + } + + private func launch(isActive: Bool, runsMissingItemsPass: Bool) -> SyncService { + let service = SyncService() + service.setup( + isActive: isActive, + libraryService: libraryService, + accountService: account, + syncQueueService: queue, + client: client, + runsMissingItemsPass: runsMissingItemsPass, + userDefaults: defaults + ) + queue.setServerLanesEnabled(false) + return service + } + + /// A lapse makes the return a first sync: what's imported meanwhile never reached the + /// server, and a plain listing would delete it. Not on the watch + @MainActor + func testLapse_makesTheReturnAFirstSync_onThePhoneOnly() async throws { + markFirstSyncDone() + let watch = launch(isActive: true, runsMissingItemsPass: false) + watch.updateSyncEnabled(false) + try await waitUntil { !watch.isActive } + XCTAssertTrue(defaults.bool(forKey: Constants.UserDefaults.hasScheduledLibraryContents)) + + sync.updateSyncEnabled(false) + + try await waitUntil { !defaults.bool(forKey: Constants.UserDefaults.hasScheduledLibraryContents) } + } + + /// A lapse that happened while the app was closed shows up at launch: signed in, not syncing + func testLaunchingLapsed_makesTheReturnAFirstSync() { + markFirstSyncDone() + account.account = accountRow(id: "user-1") + + _ = launch(isActive: false, runsMissingItemsPass: true) + + XCTAssertFalse(defaults.bool(forKey: Constants.UserDefaults.hasScheduledLibraryContents)) + } + + /// Signed out (the row stays, with a blank id), or on the watch, the flag is left alone + func testLaunchingSignedOutOrOnTheWatch_leavesTheFirstSyncFlag() { + markFirstSyncDone() + account.account = accountRow(id: "") + _ = launch(isActive: false, runsMissingItemsPass: true) + + account.account = accountRow(id: "user-1") + _ = launch(isActive: false, runsMissingItemsPass: false) + + XCTAssertTrue(defaults.bool(forKey: Constants.UserDefaults.hasScheduledLibraryContents)) + } + + /// Sync went off and came back while the first sync waited on the server: that session's + /// teardown wiped what it queued, so it mustn't mark the first sync done + @MainActor + func testFirstSync_acrossASessionChange_isNotMarkedDone() async throws { + let fresh = book("Fresh") + client.answer(unknown: [fresh.uuid]) + let sync = sync! + client.onStatus { + sync.updateSyncEnabled(false) + while await MainActor.run(body: { sync.isActive }) { try? await Task.sleep(for: .milliseconds(10)) } + sync.updateSyncEnabled(true) + while await MainActor.run(body: { !sync.isActive }) { try? await Task.sleep(for: .milliseconds(10)) } + } + + try await sync.syncLibraryContents() + + XCTAssertFalse(defaults.bool(forKey: Constants.UserDefaults.hasScheduledLibraryContents)) + let lane = await syncLane() + XCTAssertTrue(lane.isEmpty, "the old session registers nothing") + } + + /// Work queued before sync went off is held, never cleared (a paying subscriber can read + /// lapsed at launch): the first sync waits for it instead of wiping it + func testFirstSync_waitsForHeldTasks_andKeepsThem() async throws { + let held = book("Held") + await sync.jobManager.scheduleSetBookmarkJob(with: held.relativePath, time: 10, note: nil, for: held.uuid) + + try await sync.syncLibraryContents() + + let lane = await syncLane() + XCTAssertEqual(lane.map(\.jobType), [.setBookmark]) + XCTAssertTrue(client.statusRequests.isEmpty) + XCTAssertFalse(defaults.bool(forKey: Constants.UserDefaults.hasScheduledLibraryContents)) + } + + /// The first sync registers by uuid and never deletes what the server doesn't list yet + func testFirstSync_registersByUuid_andKeepsWhatTheServerLacks() async throws { + let lapseImport = book("Imported while lapsed") + client.answer(unknown: [lapseImport.uuid]) + client.setRootContent([SyncResponseFixtures.itemJSON(relativePath: "Remote.m4b")]) + defaults.set(true, forKey: Constants.UserDefaults.missingItemsPassPending) + + try await sync.syncLibraryContents() + + let exists = await libraryService.itemExists(for: lapseImport.relativePath) + XCTAssertTrue(exists, "the first sync's listing must not delete it") + let lane = await syncLane() + XCTAssertEqual(lane.map(\.jobType), [.upload]) + XCTAssertEqual(lane.last?.uuid, lapseImport.uuid) + XCTAssertTrue(defaults.bool(forKey: Constants.UserDefaults.hasScheduledLibraryContents)) + XCTAssertFalse(defaults.bool(forKey: Constants.UserDefaults.missingItemsPassPending)) + XCTAssertNotEqual(defaults.double(forKey: Constants.UserDefaults.missingItemsPassLastRun), 0) + } +} diff --git a/BookPlayerTests/Services/MultipartUploadEngineTests.swift b/BookPlayerTests/Services/MultipartUploadEngineTests.swift new file mode 100644 index 000000000..a6b6ab42d --- /dev/null +++ b/BookPlayerTests/Services/MultipartUploadEngineTests.swift @@ -0,0 +1,594 @@ +// +// MultipartUploadEngineTests.swift +// BookPlayerTests +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Combine +import Foundation +import SwiftData +import XCTest + +@testable import BookPlayer +@testable import BookPlayerKit + +/// The multipart upload engine against a scripted server and a fake part transport: +/// 4-byte parts over a 10-byte file (3 parts) +final class MultipartUploadEngineTests: XCTestCase { + private var repository: SyncQueueRepository! + private var server: ScriptedUploadServer! + private var transport: FakePartTransport! + private var fileURL: URL! + private let taskId = "upload-task" + private let uuid = "book-uuid" + + override func setUpWithError() throws { + let schema = Schema(versionedSchema: SchemaV3.self) + let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none) + let container = try ModelContainer(for: schema, configurations: config) + repository = SyncQueueRepository(tasksDataManager: TasksDataManager(container: container)) + server = ScriptedUploadServer() + transport = FakePartTransport() + transport.server = server + fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("engine-\(UUID().uuidString).m4b") + try Data("0123456789".utf8).write(to: fileURL) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: fileURL) + try? FileManager.default.removeItem(at: FileUploadOperation.partsDirectory(for: uuid)) + repository = nil + server = nil + transport = nil + super.tearDown() + } + + private func storeTask() async throws { + try await repository.storeTask(parameters: [ + "id": taskId, + "uuid": uuid, + "jobType": SyncJobType.uploadFile.rawValue, + "queueKey": TaskQueueKey.uploadFile, + "filePath": fileURL.absoluteString, + ]) + } + + private func makeOperation( + state: MultipartUploadState = .init(uploadId: nil, partSize: 0, fileSize: 0, restartCount: 0), + libraryFileURL: @escaping (String) async -> URL? = { _ in nil } + ) -> FileUploadOperation { + FileUploadOperation( + taskId: taskId, + uuid: uuid, + fileURL: fileURL, + state: state, + client: server, + repository: repository, + transport: transport, + partSize: 4, + // Never the machine's real free space: a full CI disk mustn't fail these + freeDiskSpace: { Int64.max / 2 }, + libraryFileURL: libraryFileURL + ) + } + + private func run(_ operation: FileUploadOperation, timeout: TimeInterval = 5) async { + let done = expectation(description: "operation finished") + let observer = operation.observe(\.isFinished, options: [.initial, .new]) { op, _ in + if op.isFinished { done.fulfill() } + } + operation.start() + await fulfillment(of: [done], timeout: timeout) + observer.invalidate() + } + + private func savedState() async -> MultipartUploadState? { + let task = await repository.getNextTask(for: TaskQueueKey.uploadFile) + return task.map { MultipartUploadState(parameters: $0.parameters) } + } + + // MARK: - Plan + + func testPlan_splitsTheFileIntoFixedSizeParts() { + let plan = MultipartUploadPlan(fileSize: 10, partSize: 4) + XCTAssertEqual(plan.partCount, 3) + XCTAssertEqual(plan.range(of: 1), 0..<4) + XCTAssertEqual(plan.range(of: 3), 8..<10) + XCTAssertEqual(plan.size(of: 3), 2) + XCTAssertEqual(plan.pendingParts(done: [1], active: [2]), [3]) + + let big = MultipartUploadPlan(fileSize: 6_000_000_000, partSize: FileUploadOperation.partSize) + XCTAssertEqual(big.partCount, 90) + XCTAssertEqual( + big.openSlots(window: 8, active: 3, freeBytes: Int64(10) * 1024 * 1024 * 1024, reserve: 0), + 5 + ) + // Room for two parts beyond the reserve + XCTAssertEqual( + big.openSlots(window: 8, active: 0, freeBytes: Int64(FileUploadOperation.partSize) * 2 + 100, reserve: 100), + 2 + ) + XCTAssertEqual(big.openSlots(window: 8, active: 8, freeBytes: .max / 2, reserve: 0), 0) + } + + func testPartOutcome_followsS3sAnswers() { + XCTAssertEqual(FileUploadOperation.outcome(statusCode: 200, error: nil), .uploaded) + XCTAssertEqual(FileUploadOperation.outcome(statusCode: 403, error: nil), .resend) + XCTAssertEqual(FileUploadOperation.outcome(statusCode: 404, error: nil), .uploadGone) + XCTAssertEqual(FileUploadOperation.outcome(statusCode: 500, error: nil), .failed) + XCTAssertEqual(FileUploadOperation.outcome(statusCode: 400, error: nil), .failed) + XCTAssertEqual(FileUploadOperation.outcome(statusCode: nil, error: URLError(.timedOut)), .failed) + XCTAssertEqual(FileUploadOperation.outcome(statusCode: nil, error: URLError(.cancelled)), .resend) + } + + // MARK: - Engine + + func testFreshUpload_startsSendsEveryPartInOrder_andCompletes() async throws { + try await storeTask() + let operation = makeOperation() + + await run(operation) + + XCTAssertTrue(operation.didSucceed) + XCTAssertTrue(operation.uploadCompleted) + XCTAssertEqual(server.calls(to: "/v1/library/upload/start"), 1) + XCTAssertEqual(server.calls(to: "/v1/library/upload/complete"), 1) + XCTAssertEqual(transport.startedParts, [1, 2, 3]) + XCTAssertEqual(transport.sentBytes, [1: "0123", 2: "4567", 3: "89"]) + XCTAssertEqual(server.lastComplete?["partCount"] as? Int, 3) + XCTAssertEqual(server.lastComplete?["fileSize"] as? Int64, 10) + let state = await savedState() + XCTAssertEqual(state?.uploadId, "upload-1") + XCTAssertEqual(state?.fileSize, 10) + // Parts and the temp hard link are cleaned up + XCTAssertFalse(FileManager.default.fileExists(atPath: FileUploadOperation.partsDirectory(for: uuid).path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) + } + + /// A relaunch resumes the same upload: nothing S3 has or the session is still sending + /// is sent again + func testResume_skipsPartsS3HasAndPartsStillInFlight() async throws { + try await storeTask() + server.uploadedParts = [1] + transport.inFlightAtLaunch = [2] + let operation = makeOperation(state: .init(uploadId: "upload-1", partSize: 4, fileSize: 10, restartCount: 0)) + + await run(operation) + + XCTAssertTrue(operation.uploadCompleted) + XCTAssertEqual(server.calls(to: "/v1/library/upload/start"), 0) + XCTAssertEqual(transport.startedParts, [3]) + } + + func testExpiredPartURL_isResentWithAFreshURL_notRestarted() async throws { + try await storeTask() + transport.statusByAttempt = [2: [403, 200]] + let operation = makeOperation() + + await run(operation) + + XCTAssertTrue(operation.uploadCompleted) + XCTAssertEqual(transport.startedParts, [1, 2, 3, 2]) + XCTAssertEqual(server.calls(to: "/v1/library/upload/start"), 1) + } + + func testMissingPartsAtComplete_areResent_thenCompleted() async throws { + try await storeTask() + server.completeErrors = [coded("parts_missing", status: 409)] + server.uploadedPartsAfterMissing = [1, 3] + let operation = makeOperation() + + await run(operation) + + XCTAssertTrue(operation.uploadCompleted) + XCTAssertEqual(server.calls(to: "/v1/library/upload/complete"), 2) + XCTAssertEqual(transport.startedParts, [1, 2, 3, 2]) + XCTAssertEqual(server.calls(to: "/v1/library/upload/start"), 1) + } + + func testLostUpload_startsAgain_countingTheRestart() async throws { + try await storeTask() + server.listErrors = [coded("upload_not_found", status: 409)] + let operation = makeOperation(state: .init(uploadId: "stale", partSize: 4, fileSize: 10, restartCount: 0)) + + await run(operation) + + XCTAssertTrue(operation.uploadCompleted) + XCTAssertEqual(server.calls(to: "/v1/library/upload/start"), 1) + let state = await savedState() + XCTAssertEqual(state?.restartCount, 1) + XCTAssertEqual(state?.uploadId, "upload-1") + } + + /// Past the budget the server's answer is the failure, so the queue parks the task + func testRestartBudget_whenExhausted_failsWithTheServersCode() async throws { + try await storeTask() + server.completeErrors = Array(repeating: coded("invalid_parts", status: 422), count: 10) + let operation = makeOperation() + + await run(operation) + + XCTAssertFalse(operation.didSucceed) + XCTAssertEqual(SyncFailurePolicy.codedFailure(operation.error)?.code, "invalid_parts") + XCTAssertEqual(server.calls(to: "/v1/library/upload/start"), FileUploadOperation.restartBudget + 1) + XCTAssertEqual( + SyncFailurePolicy.action(for: operation.error, jobType: .uploadFile, parkingEnabled: true), + .park(.task) + ) + // A Retry starts over: the dead upload is forgotten and the budget reset + let state = await savedState() + XCTAssertNil(state?.uploadId) + XCTAssertEqual(state?.restartCount, 0) + } + + func testAlreadyInS3_finishesWithoutSendingParts() async throws { + try await storeTask() + server.startStatus = "exists" + let operation = makeOperation() + + await run(operation) + + XCTAssertTrue(operation.uploadCompleted) + XCTAssertTrue(transport.startedParts.isEmpty) + XCTAssertEqual(server.calls(to: "/v1/library/upload/complete"), 0) + } + + func testOverTheSizeLimit_isRefusedBeforeTheServer_andParks() async throws { + try await storeTask() + let handle = try FileHandle(forWritingTo: fileURL) + try handle.truncate(atOffset: UInt64(FileUploadOperation.maxFileSize) + 1) + try handle.close() + let operation = makeOperation() + + await run(operation) + + XCTAssertFalse(operation.didSucceed) + XCTAssertEqual(operation.error as? UploadFileError, .fileTooLarge) + XCTAssertEqual(server.totalCalls, 0) + let failure = SyncFailurePolicy.codedFailure(operation.error) + XCTAssertEqual(failure?.code, "file_too_large") + XCTAssertNil(failure?.httpStatus) + XCTAssertEqual( + SyncFailurePolicy.action(for: operation.error, jobType: .uploadFile, parkingEnabled: true), + .park(.task) + ) + } + + /// Nothing to upload: the task is consumed, not retried forever + func testMissingSourceFile_isConsumed() async throws { + try await storeTask() + try FileManager.default.removeItem(at: fileURL) + let operation = makeOperation() + + await run(operation) + + XCTAssertTrue(operation.didSucceed) + XCTAssertFalse(operation.uploadCompleted) + XCTAssertEqual(server.totalCalls, 0) + } + + func testTaskClearedMidUpload_stopsTheUpload() async throws { + // Never stored: saving the state after `start` finds no task (a logout cleared it) + let operation = makeOperation() + + await run(operation) + + XCTAssertFalse(operation.didSucceed) + // Stopped by the failed save after `start`, not by an earlier failure + XCTAssertEqual(server.calls(to: "/v1/library/upload/start"), 1) + guard case .cancelledTask = operation.error as? BookPlayerError else { + return XCTFail("expected the cleared task to cancel the upload, got \(String(describing: operation.error))") + } + XCTAssertTrue(transport.startedParts.isEmpty) + } + + /// iOS purged the temp link, or the user moved the book: found again by uuid, uploaded, + /// and the real library file is never deleted + func testPurgedTempLink_uploadsTheLibraryFileFoundByUuid() async throws { + try await storeTask() + let libraryFile = FileManager.default.temporaryDirectory + .appendingPathComponent("library-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: libraryFile, withIntermediateDirectories: true) + let bookURL = libraryFile.appendingPathComponent("Book.m4b") + try Data("abcdefghij".utf8).write(to: bookURL) + defer { try? FileManager.default.removeItem(at: libraryFile) } + try FileManager.default.removeItem(at: fileURL) + let operation = makeOperation(libraryFileURL: { uuid in uuid == "book-uuid" ? bookURL : nil }) + + await run(operation) + + XCTAssertTrue(operation.uploadCompleted) + XCTAssertEqual(transport.sentBytes, [1: "abcd", 2: "efgh", 3: "ij"]) + XCTAssertTrue(FileManager.default.fileExists(atPath: bookURL.path), "the user's file must survive") + } + + /// Turning cellular on or off moves the parts in flight to the session that now applies + func testCellularSettingChange_resendsThePartsInFlight() async throws { + try await storeTask() + let key = Constants.UserDefaults.allowCellularData + let original = UserDefaults.standard.bool(forKey: key) + defer { UserDefaults.standard.set(original, forKey: key) } + transport.holdParts = true + let operation = makeOperation() + let done = expectation(description: "operation finished") + let observer = operation.observe(\.isFinished, options: [.initial, .new]) { op, _ in + if op.isFinished { done.fulfill() } + } + operation.start() + try await transport.waitForStartedParts(3) + + transport.holdParts = false + UserDefaults.standard.set(!original, forKey: key) + + await fulfillment(of: [done], timeout: 5) + observer.invalidate() + XCTAssertTrue(operation.uploadCompleted) + XCTAssertEqual(transport.startedParts.sorted(), [1, 1, 2, 2, 3, 3]) + } + + func testCancel_cancelsTheParts() async throws { + try await storeTask() + transport.holdParts = true + let operation = makeOperation() + operation.start() + try await transport.waitForStartedParts(3) + + operation.cancel() + + try await transport.waitForCancel() + XCTAssertFalse(operation.didSucceed) + XCTAssertTrue(operation.isFinished) + // The run removes the parts and the link once its loop has stopped + let deadline = Date().addingTimeInterval(2) + while Date() < deadline, + FileManager.default.fileExists(atPath: FileUploadOperation.partsDirectory(for: uuid).path) { + try await Task.sleep(for: .milliseconds(20)) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: FileUploadOperation.partsDirectory(for: uuid).path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) + } + + // MARK: - Settle (background wakes) + + /// Settles once the parts delivered so far are handled and the window refilled + func testSettle_returnsAfterTheTopUp() async throws { + try await storeTask() + transport.holdParts = true + let operation = makeOperation() + operation.start() + try await transport.waitForStartedParts(3) + + let stillRunning = await operation.settle() + + XCTAssertTrue(stillRunning) + XCTAssertEqual(transport.startedParts, [1, 2, 3]) + XCTAssertFalse(operation.isFinished) + operation.cancel() + } + + /// Asked before the engine's loop exists (it's still starting the upload): it settles + /// after the first top-up + func testSettle_beforeTheLoopStarts_waitsForTheFirstTopUp() async throws { + try await storeTask() + transport.holdParts = true + let operation = makeOperation() + let settled = Task { await operation.settle() } + try await Task.sleep(for: .milliseconds(50)) + + operation.start() + let stillRunning = await settled.value + + XCTAssertTrue(stillRunning) + XCTAssertEqual(transport.startedParts, [1, 2, 3]) + operation.cancel() + } + + /// The upload ends while a wake waits: it reports done (so the queue waits for the lane's + /// next upload), and the operation already reads as finished + func testSettle_whenTheUploadEndsFirst_reportsItDone() async throws { + try await storeTask() + server.startStatus = "exists" + let operation = makeOperation() + let settled = Task { await operation.settle() } + try await Task.sleep(for: .milliseconds(50)) + + operation.start() + let stillRunning = await settled.value + + XCTAssertFalse(stillRunning) + XCTAssertTrue(operation.isFinished) + } + + func testSettle_afterTheUploadFinished_returnsRightAway() async throws { + try await storeTask() + let operation = makeOperation() + await run(operation) + + let stillRunning = await operation.settle() + + XCTAssertFalse(stillRunning) + XCTAssertTrue(operation.uploadCompleted) + } + + private func coded(_ code: String, status: Int) -> BookPlayerError { + .networkErrorWithCode(message: code, code: code, status: status) + } +} + +// MARK: - Fakes + +/// Answers the /upload/* routes like the server would, with scripted failures +private final class ScriptedUploadServer: NetworkClientMock, @unchecked Sendable { + private let lock = NSLock() + private var callsByPath = [String: Int]() + var startStatus = "started" + var uploadedParts = [Int]() + /// What GET /parts answers after a parts_missing (S3's real list at that point) + var uploadedPartsAfterMissing: [Int]? + var listErrors = [Error]() + var completeErrors = [Error]() + private(set) var lastComplete: [String: Any]? + private var uploadCount = 0 + + init() { super.init(mockedResponse: Empty()) } + + var totalCalls: Int { lock.withLock { callsByPath.values.reduce(0, +) } } + + func calls(to path: String) -> Int { lock.withLock { callsByPath[path, default: 0] } } + + override func request( + path: String, + method: HTTPMethod, + parameters: [String: Any]? + ) async throws -> T { + let json: String = try lock.withLock { + callsByPath[path, default: 0] += 1 + switch (path, method) { + case ("/v1/library/upload/start", _): + uploadCount += 1 + uploadedParts = [] + return startStatus == "exists" + ? #"{"status":"exists"}"# + : #"{"status":"started","uploadId":"upload-\#(uploadCount)","partSize":4,"partCount":3}"# + case ("/v1/library/upload/parts", .get): + if !listErrors.isEmpty { throw listErrors.removeFirst() } + let parts = uploadedParts.map { #"{"partNumber":\#($0),"size":4}"# }.joined(separator: ",") + return #"{"parts":[\#(parts)]}"# + case ("/v1/library/upload/parts", .post): + let numbers = parameters?["partNumbers"] as? [Int] ?? [] + let parts = numbers + .map { #"{"partNumber":\#($0),"url":"https://s3.test/p\#($0)","expiresAt":1790000000}"# } + .joined(separator: ",") + return #"{"parts":[\#(parts)]}"# + case ("/v1/library/upload/complete", _): + lastComplete = parameters + if !completeErrors.isEmpty { + let error = completeErrors.removeFirst() + if let missingList = uploadedPartsAfterMissing { + uploadedParts = missingList + } + throw error + } + return #"{"synced":true}"# + default: + return "{}" + } + } + return try JSONDecoder().decode(T.self, from: Data(json.utf8)) + } + + /// A part the transport finished with 2xx is now in S3 + func recordUploaded(_ partNumber: Int) { + lock.withLock { + if !uploadedParts.contains(partNumber) { uploadedParts.append(partNumber) } + } + } +} + +/// Finishes each started part on its own, per a scripted status sequence +private final class FakePartTransport: PartUploadTransport, @unchecked Sendable { + private let lock = NSLock() + private var handler: ((PartUploadEvent) -> Void)? + private var _startedParts = [Int]() + private var _sentBytes = [Int: String]() + private var attempts = [Int: Int]() + private var cancelled = false + private var held = Set() + /// Parts the session is still sending when the operation starts; they finish shortly after + var inFlightAtLaunch = Set() + /// Per part: S3's answer on each attempt (default 200) + var statusByAttempt = [Int: [Int]]() + /// Parts never finish on their own (to test cancel) + private var _holdParts = false + var holdParts: Bool { + get { lock.withLock { _holdParts } } + set { lock.withLock { _holdParts = newValue } } + } + + var startedParts: [Int] { lock.withLock { _startedParts } } + var sentBytes: [Int: String] { lock.withLock { _sentBytes } } + + func activePartNumbers(for uuid: String, uploadId: String) async -> Set { + let pending = lock.withLock { () -> Set in + let parts = inFlightAtLaunch + inFlightAtLaunch = [] + return parts + } + for part in pending { + finish(part, status: 200, after: 0.05) + } + return pending + } + + func startPart(uuid: String, uploadId: String, partNumber: Int, file: URL, url: URL) { + let status: Int = lock.withLock { + _startedParts.append(partNumber) + _sentBytes[partNumber] = (try? String(contentsOf: file, encoding: .utf8)) ?? "" + let attempt = attempts[partNumber, default: 0] + attempts[partNumber] = attempt + 1 + let script = statusByAttempt[partNumber] ?? [] + return attempt < script.count ? script[attempt] : 200 + } + guard !holdParts else { + lock.withLock { held.insert(partNumber) } + return + } + finish(partNumber, status: status, after: 0.01) + } + + /// Held parts come back cancelled, like a session's tasks do + func cancelParts(for uuid: String) async { + let parts = lock.withLock { () -> Set in + cancelled = true + let parts = held + held = [] + return parts + } + for part in parts { + DispatchQueue.global().asyncAfter(deadline: .now() + 0.01) { [weak self] in + let handler = self?.lock.withLock { self?.handler } + handler?(.finished(partNumber: part, statusCode: nil, error: URLError(.cancelled))) + } + } + } + + func subscribe(uuid: String, uploadId: String, handler: @escaping (PartUploadEvent) -> Void) -> AnyCancellable { + lock.withLock { self.handler = handler } + return AnyCancellable { [weak self] in + self?.lock.withLock { self?.handler = nil } + } + } + + /// Set by the test so a 2xx lands in the scripted server's part list + weak var server: AnyObject? + + private func finish(_ partNumber: Int, status: Int, after delay: TimeInterval) { + DispatchQueue.global().asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + if (200...299).contains(status) { + (self.server as? ScriptedUploadServer)?.recordUploaded(partNumber) + } + let handler = self.lock.withLock { self.handler } + handler?(.finished(partNumber: partNumber, statusCode: status, error: nil)) + } + } + + func waitForStartedParts(_ count: Int, timeout: TimeInterval = 3) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if startedParts.count >= count { return } + try await Task.sleep(for: .milliseconds(10)) + } + XCTFail("timed out waiting for \(count) started parts") + } + + func waitForCancel(timeout: TimeInterval = 3) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if lock.withLock({ cancelled }) { return } + try await Task.sleep(for: .milliseconds(10)) + } + XCTFail("timed out waiting for the parts to be cancelled") + } +} diff --git a/BookPlayerTests/Services/PreferencesSyncServiceTests.swift b/BookPlayerTests/Services/PreferencesSyncServiceTests.swift index 370573dd9..4068c1053 100644 --- a/BookPlayerTests/Services/PreferencesSyncServiceTests.swift +++ b/BookPlayerTests/Services/PreferencesSyncServiceTests.swift @@ -615,6 +615,7 @@ private final class SyncToggleAccountStub: AccountServiceProtocol { func hasSyncEnabled() -> Bool { syncEnabled } func hasPlusAccess() -> Bool { syncEnabled } + func hasEverSubscribed() -> Bool { false } func hasAccount() -> Bool { false } func getAccountId() -> String? { nil } func getAccount() -> Account? { nil } @@ -642,4 +643,7 @@ private final class SyncToggleAccountStub: AccountServiceProtocol { func restorePurchases() async throws -> CustomerInfo { try await Purchases.shared.customerInfo() } func logout() throws {} func deleteAccount() async throws -> String { "" } + func getAccessLevel() -> BookPlayerKit.AccessLevel { + return .plus + } } diff --git a/BookPlayerTests/Services/SyncPauseReportingTests.swift b/BookPlayerTests/Services/SyncPauseReportingTests.swift new file mode 100644 index 000000000..557685ffb --- /dev/null +++ b/BookPlayerTests/Services/SyncPauseReportingTests.swift @@ -0,0 +1,243 @@ +// +// SyncPauseReportingTests.swift +// BookPlayerTests +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation +import Sentry +import SwiftData +import XCTest + +@testable import BookPlayer +@testable import BookPlayerKit + +/// Sentry reporting and the Report attachment for parked sync tasks +final class SyncPauseReportingTests: XCTestCase { + private var tasksDataManager: TasksDataManager! + private var repository: SyncQueueRepository! + private var service: SyncQueueService! + + override func setUpWithError() throws { + let schema = Schema(versionedSchema: SchemaV3.self) + let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none) + let container = try ModelContainer(for: schema, configurations: config) + tasksDataManager = TasksDataManager(container: container) + repository = SyncQueueRepository(tasksDataManager: tasksDataManager) + service = SyncQueueService(maxConcurrentTasks: 1) + service.taskContainer = repository + service.tasksDataManager = tasksDataManager + } + + override func tearDown() { + service = nil + repository = nil + tasksDataManager = nil + super.tearDown() + } + + private func pausedTask( + id: String = "task-1", + sentryEventId: String? = nil + ) -> QueuedSyncTask { + QueuedSyncTask( + id: id, + queueKey: TaskQueueKey.sync, + jobType: .shallowDelete, + parameters: [:], + uuid: "item-uuid", + relativePath: "Private Folder/Secret Book.mp3", + pause: TaskPause( + scope: .lane, + errorCode: "item_not_found", + message: "Item not found: \"Private Folder/Secret Book.mp3\"", + httpStatus: 404, + pausedAt: Date(timeIntervalSince1970: 0), + sentryEventId: sentryEventId + ) + ) + } + + // MARK: - Sentry + + /// Minimal payload: the failure's identity only — never the server message or the path, + /// which embed file names + @MainActor + func testReporter_sendsTheMinimalEvent_andRecordsItsId() async throws { + try await repository.storeTask(parameters: [ + "id": "task-1", + "uuid": "item-uuid", + "jobType": SyncJobType.shallowDelete.rawValue, + "queueKey": TaskQueueKey.sync, + "relativePath": "Private Folder/Secret Book.mp3", + ]) + await repository.park( + taskId: "task-1", + pause: TaskPause(scope: .lane, errorCode: "item_not_found", message: "m", httpStatus: 404, pausedAt: Date()) + ) + + var captured = [Event]() + let eventId = SentryId() + let reporter = SyncPauseReporter(syncQueueService: service) { event in + captured.append(event) + return eventId + } + + reporter.report(pausedTask()) + + XCTAssertEqual(captured.count, 1) + let event = try XCTUnwrap(captured.first) + XCTAssertEqual(event.level, .warning) + XCTAssertEqual(event.fingerprint, ["sync-paused", "shallowDelete", "item_not_found"]) + XCTAssertEqual(event.tags?["sync.job_type"], "shallowDelete") + XCTAssertEqual(event.tags?["sync.error_code"], "item_not_found") + XCTAssertEqual(event.tags?["sync.http_status"], "404") + XCTAssertEqual(event.tags?["sync.lane"], TaskQueueKey.sync) + XCTAssertEqual(event.tags?["sync.pause_scope"], "lane") + XCTAssertEqual(event.extra?["item_uuid"] as? String, "item-uuid") + + let payload = [ + event.message?.formatted ?? "", + "\(event.tags ?? [:])", + "\(event.extra ?? [:])", + ].joined() + XCTAssertFalse(payload.contains("Secret Book"), "file names must never reach Sentry") + + let deadline = Date().addingTimeInterval(2) + var stored: String? + while Date() < deadline { + stored = await repository.getAllTasks().first?.pause?.sentryEventId + if stored != nil { break } + try await Task.sleep(for: .milliseconds(20)) + } + XCTAssertEqual(stored, eventId.sentryIdString) + } + + /// Once per task: already reported (stored id), or reported earlier this session before + /// the id was written back + @MainActor + func testReporter_reportsEachTaskOnce() { + var captures = 0 + let reporter = SyncPauseReporter(syncQueueService: service) { _ in + captures += 1 + return SentryId() + } + + reporter.report(pausedTask(id: "reported", sentryEventId: "evt-1")) + XCTAssertEqual(captures, 0) + + reporter.report(pausedTask(id: "new")) + reporter.report(pausedTask(id: "new")) + XCTAssertEqual(captures, 1) + } + + /// Crash reports off: nothing was sent, so the task must stay unreported + @MainActor + func testReporter_withSentryDisabled_leavesTheTaskUnreported() async throws { + try await repository.storeTask(parameters: [ + "id": "task-1", + "uuid": "item-uuid", + "jobType": SyncJobType.shallowDelete.rawValue, + "queueKey": TaskQueueKey.sync, + "relativePath": "a.mp3", + ]) + await repository.park( + taskId: "task-1", + pause: TaskPause(scope: .lane, errorCode: "item_not_found", message: "m", httpStatus: 404, pausedAt: Date()) + ) + let reporter = SyncPauseReporter(syncQueueService: service) { _ in SentryId.empty } + + reporter.report(pausedTask()) + try await Task.sleep(for: .milliseconds(300)) + + let stored = await repository.getAllTasks().first?.pause + XCTAssertNotNil(stored) + XCTAssertNil(stored?.sentryEventId) + } + + /// Turning crash reports back on mid-session: the next park of the same task reports + @MainActor + func testReporter_retriesATaskWhoseReportWasNotSent() { + var sentryEnabled = false + var captures = 0 + let reporter = SyncPauseReporter(syncQueueService: service) { _ in + captures += 1 + return sentryEnabled ? SentryId() : SentryId.empty + } + + reporter.report(pausedTask(id: "later")) + sentryEnabled = true + reporter.report(pausedTask(id: "later")) + reporter.report(pausedTask(id: "later")) + + XCTAssertEqual(captures, 2, "unsent once, then sent once") + } + + @MainActor + func testReporter_observesThePausedNotification() { + var captures = 0 + let reporter = SyncPauseReporter(syncQueueService: service) { _ in + captures += 1 + return SentryId() + } + + NotificationCenter.default.post(name: .syncTaskPaused, object: pausedTask(id: "notified")) + + XCTAssertEqual(captures, 1) + withExtendedLifetime(reporter) {} + } + + // MARK: - Report attachment + + func testReport_listsThePausedTask_theQueue_andTheLocalLibraryWithUuids() { + let pending = QueuedSyncTask( + id: "task-2", + queueKey: TaskQueueKey.uploadFile, + jobType: .uploadFile, + parameters: [:], + uuid: "upload-uuid", + relativePath: "Book 2.mp3" + ) + let report = SyncPauseReport( + pausedTask: pausedTask(sentryEventId: "evt-9"), + queuedTasks: [pausedTask(sentryEventId: "evt-9"), pending], + library: [("Private Folder", "folder-uuid"), ("Private Folder/Secret Book.mp3", "item-uuid")], + appVersion: "6.0.0-100c", + systemVersion: "iOS 26.0", + deviceName: "iPhone" + ) + + let text = report.text + XCTAssertTrue(text.contains("App: 6.0.0-100c")) + XCTAssertTrue(text.contains("Status: paused (lane) · item_not_found 404")) + XCTAssertTrue(text.contains("Message: Item not found")) + XCTAssertTrue(text.contains("Sentry event: evt-9")) + XCTAssertTrue(text.contains("-- Queued tasks (2) --")) + XCTAssertTrue(text.contains("uploadFile · lane uploadFile\n Status: pending")) + XCTAssertTrue(text.contains("Secret Book.mp3 (item-uuid)")) + XCTAssertTrue(text.contains("Private Folder (folder-uuid)")) + XCTAssertTrue(report.subject.contains("item_not_found")) + } + + /// Local state only: whether each item's file is on this device, with its uuid when given + func testLibraryTree_showsLocalFilesAndUuids() { + let tree = LibraryTreeRepresentation.render(entries: [("Book.mp3", "book-uuid"), ("Other.mp3", nil)]) + XCTAssertEqual(tree, "Library\n.\n|-- [𐄂] Book.mp3 (book-uuid)\n`-- [𐄂] Other.mp3\n") + } + + func testReport_writesANamedFile() throws { + let report = SyncPauseReport( + pausedTask: pausedTask(), + queuedTasks: [], + library: [], + appVersion: "1", + systemVersion: "iOS", + deviceName: "iPhone" + ) + let url = try report.writeToTemporaryFile() + defer { try? FileManager.default.removeItem(at: url) } + XCTAssertEqual(url.lastPathComponent, SyncPauseReport.fileName) + XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), report.text) + } +} diff --git a/BookPlayerTests/Services/SyncQueueTests.swift b/BookPlayerTests/Services/SyncQueueTests.swift new file mode 100644 index 000000000..5a88b8d54 --- /dev/null +++ b/BookPlayerTests/Services/SyncQueueTests.swift @@ -0,0 +1,1582 @@ +// +// SyncQueueTests.swift +// BookPlayerTests +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Combine +import Foundation +import SwiftData +import XCTest + +@testable import BookPlayer +@testable import BookPlayerKit + +/// First dedicated coverage for the sync-queue engine: task persistence +/// round-trips (notably `hostId`, whose omission silently discarded every persisted progress +/// push), queue ordering, the per-tier access policy, and the operation state machine. +final class SyncQueueTests: XCTestCase { + private var tasksDataManager: TasksDataManager! + private var repository: SyncQueueRepository! + + override func setUpWithError() throws { + let schema = Schema([ + UploadTaskModel.self, + UpdateTaskModel.self, + MoveTaskModel.self, + DeleteTaskModel.self, + DeleteBookmarkTaskModel.self, + SetBookmarkTaskModel.self, + RenameFolderTaskModel.self, + ArtworkUploadTaskModel.self, + MatchUuidsTaskModel.self, + UploadExternalResourceTaskModel.self, + ExternalResourceToDownloadTaskModel.self, + DeleteExternalResourceTaskModel.self, + SyncQueueContainer.self, + QueuedTaskReferenceModel.self, + ExternalUpdateTaskModel.self, + UploadFileTaskModel.self, + ]) + let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none) + let container = try ModelContainer(for: schema, configurations: config) + tasksDataManager = TasksDataManager(container: container) + repository = SyncQueueRepository(tasksDataManager: tasksDataManager) + } + + override func tearDown() { + repository = nil + tasksDataManager = nil + super.tearDown() + } + + private func externalUpdateParams( + id: String = UUID().uuidString, + providerId: String = "item-1", + hostId: String? = "82f33a82610b4869879615f9c6cb1ece" + ) -> [String: Any] { + var params: [String: Any] = [ + "id": id, + "jobType": SyncJobType.externalUpdate.rawValue, + "queueKey": "jellyfin", + "providerName": "jellyfin", + "providerId": providerId, + "currentTime": 123.5, + "percentCompleted": 42.0, + ] + if let hostId { + params["hostId"] = hostId + } + return params + } + + // MARK: - Persistence round-trips + + /// The whole progress-push path routes by stable host: dropping `hostId` between store and + /// reload makes the operation resolve no connection and (correctly) discard EVERY push. + func testExternalUpdateTask_roundTripsHostId() async throws { + try await repository.storeTask(parameters: externalUpdateParams()) + + // getNextTask is the accessor the WORKER uses to reload persisted tasks — the payload + // join happens there (getAllTasks is a display-level list with empty parameters). + let task = await repository.getNextTask(for: "jellyfin") + let params = try XCTUnwrap(task?.parameters) + XCTAssertEqual(params["hostId"] as? String, "82f33a82610b4869879615f9c6cb1ece") + XCTAssertEqual(params["providerId"] as? String, "item-1") + XCTAssertEqual(params["providerName"] as? String, "jellyfin") + XCTAssertEqual(params["currentTime"] as? Double, 123.5) + XCTAssertEqual(params["percentCompleted"] as? Double, 42.0) + } + + func testExternalUpdateTask_withoutHostId_roundTripsAsAbsent() async throws { + try await repository.storeTask(parameters: externalUpdateParams(hostId: nil)) + + let task = await repository.getNextTask(for: "jellyfin") + XCTAssertNotNil(task) + XCTAssertNil(task?.parameters["hostId"]) + } + + func testTasks_preserveInsertionOrder() async throws { + try await repository.storeTask(parameters: externalUpdateParams(id: "task-a", providerId: "a")) + try await repository.storeTask(parameters: externalUpdateParams(id: "task-b", providerId: "b")) + try await repository.storeTask(parameters: externalUpdateParams(id: "task-c", providerId: "c")) + + // FIFO through the worker's own next+pop cycle. + var seen = [String]() + while let task = await repository.getNextTask(for: "jellyfin") { + seen.append(task.parameters["providerId"] as? String ?? "?") + await repository.pop(task) + } + XCTAssertEqual(seen, ["a", "b", "c"]) + } + + func testGetOrderedTasks_putsActiveTasksFirst() async throws { + try await repository.storeTask(parameters: externalUpdateParams(id: "task-a", providerId: "a")) + try await repository.storeTask(parameters: externalUpdateParams(id: "task-b", providerId: "b")) + + let ordered = await repository.getOrderedTasks(activeTaskIDs: ["task-b"]) + XCTAssertEqual(ordered.first?.id, "task-b") + XCTAssertEqual(ordered.count, 2) + } + + func testPop_removesTheTask() async throws { + try await repository.storeTask(parameters: externalUpdateParams(id: "task-a")) + let tasks = await repository.getAllTasks() + let task = try XCTUnwrap(tasks.first) + + await repository.pop(task) + + let remaining = await repository.getAllTasks() + XCTAssertTrue(remaining.isEmpty) + } + + // MARK: - Access policy (per-tier gating) + + /// `externalUpdate` targets the USER'S OWN media server, so it stays available on every + /// tier (Android parity); `uploadFile` (S3) is PRO-only. + func testAccessPolicy_perTier() { + let service = SyncQueueService(maxConcurrentTasks: 1) + + service.updateAccessPolicy(.pro) + XCTAssertEqual(service.accessPolicy[.externalUpdate], true) + XCTAssertEqual(service.accessPolicy[.uploadFile], true) + + service.updateAccessPolicy(.lite) + XCTAssertEqual(service.accessPolicy[.externalUpdate], true) + XCTAssertEqual(service.accessPolicy[.uploadFile], false) + + service.updateAccessPolicy(.free) + XCTAssertEqual(service.accessPolicy[.externalUpdate], true) + XCTAssertEqual(service.accessPolicy[.uploadFile], false) + + service.updateAccessPolicy(.plus) + XCTAssertEqual(service.accessPolicy[.externalUpdate], true) + XCTAssertEqual(service.accessPolicy[.uploadFile], false) + } + + func testScheduleMetadataUpdate_gatedByPolicy() async throws { + let service = SyncQueueService(maxConcurrentTasks: 1) + service.taskContainer = repository + + // Denied: nothing is persisted. + service.accessPolicy = [.externalUpdate: false] + service.scheduleMetadataUpdate(params: ["providerName": "jellyfin", "providerId": "item-1"]) + try await Task.sleep(for: .milliseconds(300)) + var tasks = await repository.getAllTasks() + XCTAssertTrue(tasks.isEmpty) + + // Allowed: the task lands with the right jobType and provider queue. + service.accessPolicy = [.externalUpdate: true] + service.scheduleMetadataUpdate(params: [ + "providerName": "jellyfin", + "providerId": "item-1", + "hostId": "guid-1", + ]) + try await waitForTaskCount(1) + let stored = await repository.getNextTask(for: "jellyfin") + XCTAssertEqual(stored?.jobType, .externalUpdate) + XCTAssertEqual(stored?.queueKey, "jellyfin") + XCTAssertEqual(stored?.parameters["hostId"] as? String, "guid-1") + } + + /// A denied upload never reads the temp hard link scheduled for it, so it's removed. + func testScheduleFileUpload_denied_removesTheTempHardLink() async throws { + let service = SyncQueueService(maxConcurrentTasks: 1) + service.taskContainer = repository + service.accessPolicy = [.uploadFile: false] + let link = FileManager.default.temporaryDirectory.appendingPathComponent("denied-\(UUID().uuidString).mp3") + try Data("x".utf8).write(to: link) + + service.scheduleFileUpload(params: ["filePath": link.absoluteString, "uuid": "u1"]) + + XCTAssertFalse(FileManager.default.fileExists(atPath: link.path)) + let tasks = await repository.getAllTasks() + XCTAssertTrue(tasks.isEmpty) + } + + func testScheduleFileUpload_gatedByPolicy() async throws { + let service = SyncQueueService(maxConcurrentTasks: 1) + service.taskContainer = repository + + service.accessPolicy = [.uploadFile: false] + service.scheduleFileUpload(params: ["filePath": "/tmp/a", "uuid": "u1"]) + try await Task.sleep(for: .milliseconds(300)) + let denied = await repository.getAllTasks() + XCTAssertTrue(denied.isEmpty) + + service.accessPolicy = [.uploadFile: true] + service.scheduleFileUpload(params: ["filePath": "/tmp/a", "uuid": "u1"]) + try await waitForTaskCount(1) + let allowed = await repository.getNextTask(for: TaskQueueKey.uploadFile) + XCTAssertEqual(allowed?.jobType, .uploadFile) + XCTAssertEqual(allowed?.parameters["filePath"] as? String, "/tmp/a") + } + + // MARK: - Operation state machine + + func testAsyncOperation_finishFlipsStateFromAnotherThread() { + let operation = AsyncOperation() + XCTAssertTrue(operation.isReady) + XCTAssertFalse(operation.isExecuting) + + operation.start() + XCTAssertTrue(operation.isExecuting) + + let finished = expectation(description: "finished") + DispatchQueue.global().async { + operation.finish() + finished.fulfill() + } + wait(for: [finished], timeout: 2) + XCTAssertTrue(operation.isFinished) + XCTAssertFalse(operation.isExecuting) + } + + func testAsyncOperation_cancelledBeforeStartFinishesImmediately() { + let operation = AsyncOperation() + operation.cancel() + operation.start() + XCTAssertTrue(operation.isFinished) + } + + // MARK: - Helpers + + private func waitForTaskCount(_ count: Int, timeout: TimeInterval = 3) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let tasks = await repository.getAllTasks() + if tasks.count >= count { return } + try await Task.sleep(for: .milliseconds(50)) + } + XCTFail("timed out waiting for \(count) task(s)") + } +} + +// MARK: - Policy ownership (self-refresh on account updates) + +extension SyncQueueTests { + /// The mid-session-upgrade pin: the service re-derives its per-job policy from + /// .accountUpdate on its own — no coordinator wiring. Before this, an upgrade left + /// file uploads gated off until the next app start unless every platform remembered + /// to forward the new level manually. + @MainActor + func testAccessPolicyRefreshesOnAccountUpdate() { + let service = SyncQueueService(maxConcurrentTasks: 1) + var level = AccessLevel.free + service.getAccessLevel = { level } + service.bindAccountObserver() + service.updateAccessPolicy(level) + XCTAssertEqual(service.accessPolicy[.uploadFile], false) + + level = .pro + NotificationCenter.default.post(name: .accountUpdate, object: nil) + + // The sink hops through DispatchQueue.main; one enqueued block after the post + // is guaranteed to run after the delivery. + let refreshed = expectation(description: "policy refreshed") + DispatchQueue.main.async { refreshed.fulfill() } + wait(for: [refreshed], timeout: 1) + + XCTAssertEqual(service.accessPolicy[.uploadFile], true, "mid-session upgrade must reach the policy") + XCTAssertEqual(service.accessPolicy[.externalUpdate], true) + } +} + +// MARK: - Queue counts (engine-owned) + +extension SyncQueueTests { + private func syncUpdateParams(id: String, relativePath: String) -> [String: Any] { + [ + "id": id, + "uuid": UUID().uuidString, + "jobType": SyncJobType.update.rawValue, + "queueKey": TaskQueueKey.sync, + "relativePath": relativePath, + ] + } + + private func uploadFileParams(id: String) -> [String: Any] { + [ + "id": id, + "jobType": SyncJobType.uploadFile.rawValue, + "queueKey": TaskQueueKey.uploadFile, + "filePath": "/tmp/\(id).m4b", + "uuid": id, + ] + } + + /// First snapshot satisfying `predicate`. The publisher replays its latest value on + /// subscribe, so a state reached BEFORE subscribing still resolves. + private func awaitCounts( + from publisher: AnyPublisher, + timeout: TimeInterval = 3, + where predicate: @escaping (QueueCounts) -> Bool + ) async throws -> QueueCounts { + let matched = expectation(description: "queue counts matched") + var result: QueueCounts? + let subscription = publisher.sink { counts in + guard result == nil, predicate(counts) else { return } + result = counts + matched.fulfill() + } + await fulfillment(of: [matched], timeout: timeout) + subscription.cancel() + return try XCTUnwrap(result) + } + + func testQueueCounts_totalAndPerLane() { + let counts = QueueCounts(byQueueKey: [TaskQueueKey.sync: 2, "jellyfin": 3]) + XCTAssertEqual(counts.total, 5) + XCTAssertEqual(counts.count(in: "jellyfin"), 3) + XCTAssertEqual(counts.count(in: TaskQueueKey.uploadFile), 0, "an absent lane reads as zero") + XCTAssertEqual(QueueCounts().total, 0) + } + + /// The engine owns every lane, so ONE publisher carries all counts: the Profile row sums + /// it, the sectioned screen reads one lane at a time. + func testQueueCounts_trackEveryLane() async throws { + try await repository.storeTask(parameters: externalUpdateParams(id: "j1", providerId: "a")) + try await repository.storeTask(parameters: externalUpdateParams(id: "j2", providerId: "b")) + try await repository.storeTask(parameters: uploadFileParams(id: "u1")) + try await repository.storeTask(parameters: syncUpdateParams(id: "s1", relativePath: "book.m4b")) + + let counts = try await awaitCounts(from: tasksDataManager.observeQueueCounts()) { $0.total == 4 } + XCTAssertEqual(counts.count(in: "jellyfin"), 2) + XCTAssertEqual(counts.count(in: TaskQueueKey.uploadFile), 1) + XCTAssertEqual(counts.count(in: TaskQueueKey.sync), 1) + XCTAssertEqual(counts.byQueueKey.count, 3) + } + + func testQueueCounts_drainedLaneLeavesTheSnapshot() async throws { + try await repository.storeTask(parameters: externalUpdateParams(id: "j1")) + _ = try await awaitCounts(from: tasksDataManager.observeQueueCounts()) { $0.total == 1 } + let next = await repository.getNextTask(for: "jellyfin") + let task = try XCTUnwrap(next) + + await repository.pop(task) + + let counts = try await awaitCounts(from: tasksDataManager.observeQueueCounts()) { $0.total == 0 } + XCTAssertEqual(counts.count(in: "jellyfin"), 0) + XCTAssertNil(counts.byQueueKey["jellyfin"]) + } + + /// Pins the list-refresh gate: `SyncService.canSyncListContents` reads the sync lane only + /// (`getTasksCount(in:)`), so a heavy S3 upload or a provider push never blocks a refresh. + func testSyncLaneCount_ignoresUploadsAndProviderPushes() async throws { + try await repository.storeTask(parameters: uploadFileParams(id: "u1")) + try await repository.storeTask(parameters: externalUpdateParams(id: "j1")) + + let syncLaneCount = await repository.getTasksCount(in: TaskQueueKey.sync) + XCTAssertEqual(syncLaneCount, 0) + let counts = try await awaitCounts(from: tasksDataManager.observeQueueCounts()) { $0.total == 2 } + XCTAssertEqual(counts.count(in: TaskQueueKey.sync), 0) + } + + /// The engine forwards the store owner's publisher — no second bookkeeping anywhere. + func testEngine_observeQueueCounts_forwardsTheStoreOwner() async throws { + let service = SyncQueueService(maxConcurrentTasks: 1) + service.taskContainer = repository + service.tasksDataManager = tasksDataManager + try await repository.storeTask(parameters: externalUpdateParams(id: "j1")) + + let counts = try await awaitCounts(from: service.observeQueueCounts()) { $0.total == 1 } + XCTAssertEqual(counts.count(in: "jellyfin"), 1) + } + + /// Background refresh waits on this: only the named lane matters, whatever the others hold. + func testLaneDrained_followsOnlyTheNamedLane() { + let subject = CurrentValueSubject( + QueueCounts(byQueueKey: [TaskQueueKey.sync: 1, TaskQueueKey.uploadFile: 3]) + ) + var seen = [Bool]() + let subscription = subject.laneDrained(TaskQueueKey.sync).sink { seen.append($0) } + defer { subscription.cancel() } + + subject.send(QueueCounts(byQueueKey: [TaskQueueKey.uploadFile: 3])) + subject.send(QueueCounts(byQueueKey: [TaskQueueKey.uploadFile: 3, "jellyfin": 2])) + + XCTAssertEqual(seen, [false, true, true]) + } +} + +// MARK: - Server-lane gate (sync off holds the BookPlayer-server lanes) + +extension SyncQueueTests { + /// Engine wired to the in-memory repository. The upload's source file doesn't exist, so + /// once it runs the operation consumes it without any network: the pop is the signal. + private func makeGatedEngine() -> SyncQueueService { + let service = SyncQueueService(maxConcurrentTasks: 1) + service.taskContainer = repository + service.tasksDataManager = tasksDataManager + service.accessPolicy = [.uploadFile: true, .externalUpdate: true] + service.networkClient = NetworkClientMock(mockedResponse: Empty()) + return service + } + + private func waitForEmptyQueue(timeout: TimeInterval = 3) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await repository.getAllTasks().isEmpty { return } + try await Task.sleep(for: .milliseconds(50)) + } + XCTFail("timed out waiting for the queue to drain") + } + + /// The lapsed-subscriber retry storm: a persisted server-lane task must not run while + /// sync is off, must survive (not be cleared), and must run once sync turns on. + func testServerLanes_holdTasksWhileGated_andRunThemOnEnable() async throws { + let service = makeGatedEngine() + var params = uploadFileParams(id: "gated-1") + params["filePath"] = URL(fileURLWithPath: "/nonexistent/\(UUID().uuidString).m4b").absoluteString + try await repository.storeTask(parameters: params) + + XCTAssertFalse(service.serverLanesEnabled) + service.wakeUpWorkers() + try await Task.sleep(for: .milliseconds(400)) + let held = await repository.getAllTasks() + XCTAssertEqual(held.count, 1, "a gated lane must neither run nor drop its tasks") + + service.setServerLanesEnabled(true) + try await waitForEmptyQueue() + } + + /// A lapse while a worker is mid-lane: the running task finishes, the next one is held + /// (not popped, not run), and it runs once sync is back on. + func testServerLanes_runningWorkerStopsAfterItsCurrentTask_whenGatedMidLane() async throws { + let service = makeGatedEngine() + let client = BlockingNetworkClient() + service.networkClient = client + for (id, path) in [("del-1", "a.mp3"), ("del-2", "b.mp3")] { + try await repository.storeTask(parameters: [ + "id": id, + "uuid": UUID().uuidString, + "jobType": SyncJobType.delete.rawValue, + "queueKey": TaskQueueKey.sync, + "relativePath": path, + ]) + } + + service.setServerLanesEnabled(true) + try await client.waitForRequests(1) + service.setServerLanesEnabled(false) + client.release() + + try await waitForTaskCount(exactly: 1) + try await Task.sleep(for: .milliseconds(300)) + let held = await repository.getAllTasks() + XCTAssertEqual(held.map(\.id), ["del-2"]) + XCTAssertEqual(client.requestCount, 1, "the held task must not reach the server") + + service.setServerLanesEnabled(true) + try await waitForEmptyQueue() + XCTAssertEqual(client.requestCount, 2) + } + + private func waitForTaskCount(exactly count: Int, timeout: TimeInterval = 3) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await repository.getAllTasks().count == count { return } + try await Task.sleep(for: .milliseconds(50)) + } + XCTFail("timed out waiting for exactly \(count) task(s)") + } + + /// Provider pushes go to the user's own media server, on every tier: the gate leaves them alone. + func testProviderLanes_runWhileServerLanesAreGated() async throws { + let service = makeGatedEngine() + // Non-finite position: the engine discards (pops) it without any network call + var params = externalUpdateParams(id: "push-1") + params["currentTime"] = Double.infinity + try await repository.storeTask(parameters: params) + + service.wakeUpWorkers() + try await waitForEmptyQueue() + XCTAssertFalse(service.serverLanesEnabled) + } + + /// SyncService is the one writer: setup seeds the gate from `isActive`, and every + /// enable/disable and logout moves it with the flag. + @MainActor + func testSyncService_drivesTheServerLaneGate() async throws { + let queue = makeGatedEngine() + let sync = SyncService() + sync.setup( + isActive: false, + libraryService: LibraryService(), + accountService: AccountServiceMock(account: nil), + syncQueueService: queue, + client: NetworkClientMock(mockedResponse: Empty()) + ) + XCTAssertFalse(queue.serverLanesEnabled) + + sync.updateSyncEnabled(true) + try await waitUntil { queue.serverLanesEnabled } + XCTAssertTrue(sync.isActive) + + sync.updateSyncEnabled(false) + try await waitUntil { !queue.serverLanesEnabled } + + sync.updateSyncEnabled(true) + try await waitUntil { queue.serverLanesEnabled } + await sync.logout() + XCTAssertFalse(queue.serverLanesEnabled) + XCTAssertFalse(sync.isActive) + } + + @MainActor + private func waitUntil(timeout: TimeInterval = 2, _ condition: () -> Bool) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return } + try await Task.sleep(for: .milliseconds(20)) + } + XCTFail("timed out waiting for the condition") + } +} + +/// Answers `Empty` like `NetworkClientMock`, but holds the FIRST request until `release()`, +/// so a test can act while an operation is in flight. +private final class BlockingNetworkClient: NetworkClientMock, @unchecked Sendable { + private let lock = NSLock() + private var _requestCount = 0 + private var gate: CheckedContinuation? + private var released = false + + var requestCount: Int { lock.withLock { _requestCount } } + + init() { super.init(mockedResponse: Empty()) } + + override func request( + path: String, + method: HTTPMethod, + parameters: [String: Any]? + ) async throws -> T { + let isFirst = lock.withLock { + _requestCount += 1 + return _requestCount == 1 && !released + } + if isFirst { + await withCheckedContinuation { continuation in + let resumeNow = lock.withLock { + if released { return true } + gate = continuation + return false + } + if resumeNow { continuation.resume() } + } + } + // swiftlint:disable:next force_cast + return Empty() as! T + } + + func release() { + let continuation = lock.withLock { + released = true + defer { gate = nil } + return gate + } + continuation?.resume() + } + + func waitForRequests(_ count: Int, timeout: TimeInterval = 3) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if requestCount >= count { return } + try await Task.sleep(for: .milliseconds(20)) + } + XCTFail("timed out waiting for \(count) request(s)") + } +} + +// MARK: - Parking (coded server errors) + +extension SyncQueueTests { + private func coded(_ code: String, status: Int = 404) -> BookPlayerError { + .networkErrorWithCode(message: "Item not found: \"a.mp3\"", code: code, status: status) + } + + private func syncTaskParams(id: String, jobType: SyncJobType, path: String) -> [String: Any] { + [ + "id": id, + "uuid": UUID().uuidString, + "jobType": jobType.rawValue, + "queueKey": TaskQueueKey.sync, + "relativePath": path, + ] + } + + private func pause(_ scope: TaskPauseScope, code: String = "item_not_found") -> TaskPause { + TaskPause(scope: scope, errorCode: code, message: "msg", httpStatus: 404, pausedAt: Date()) + } + + func testFailurePolicy_parksOnlyCodedErrors_scopedByJobType() { + typealias Policy = SyncFailurePolicy + XCTAssertEqual(Policy.action(for: nil, jobType: .move, parkingEnabled: true), .retry) + XCTAssertEqual(Policy.action(for: BookPlayerError.networkError("x"), jobType: .move, parkingEnabled: true), .retry) + XCTAssertEqual(Policy.action(for: URLError(.timedOut), jobType: .move, parkingEnabled: true), .retry) + + XCTAssertEqual(Policy.action(for: coded("item_not_found"), jobType: .move, parkingEnabled: true), .park(.lane)) + XCTAssertEqual(Policy.action(for: coded("uuid_conflict", status: 409), jobType: .upload, parkingEnabled: true), .park(.lane)) + XCTAssertEqual(Policy.action(for: coded("item_not_found"), jobType: .matchUuid, parkingEnabled: true), .park(.lane)) + XCTAssertEqual(Policy.action(for: coded("item_not_found"), jobType: .setBookmark, parkingEnabled: true), .park(.lane)) + XCTAssertEqual(Policy.action(for: coded("invalid_request", status: 400), jobType: .update, parkingEnabled: true), .park(.task)) + XCTAssertEqual(Policy.action(for: coded("item_not_found"), jobType: .uploadArtwork, parkingEnabled: true), .park(.task)) + + XCTAssertEqual(Policy.action(for: coded("not_subscribed", status: 400), jobType: .move, parkingEnabled: true), .verifyAccount) + XCTAssertEqual(Policy.action(for: coded("tier_required", status: 403), jobType: .uploadFile, parkingEnabled: true), .verifyAccount) + + // Watch: task-level failures drop, account-level handling is the same + XCTAssertEqual(Policy.action(for: coded("item_not_found"), jobType: .move, parkingEnabled: false), .drop) + XCTAssertEqual(Policy.action(for: coded("not_subscribed", status: 400), jobType: .move, parkingEnabled: false), .verifyAccount) + + // Media-server pushes keep their own handling, account codes included + XCTAssertEqual(Policy.action(for: coded("item_not_found"), jobType: .externalUpdate, parkingEnabled: true), .retry) + XCTAssertEqual(Policy.action(for: coded("not_subscribed", status: 400), jobType: .externalUpdate, parkingEnabled: true), .retry) + + // A logout/lapse cancellation is not the server's answer + XCTAssertEqual(Policy.action(for: BookPlayerError.cancelledTask, jobType: .move, parkingEnabled: true), .retry) + } + + func testGetNextTask_skipsTaskPausedRows_andStopsAtALanePause() async throws { + try await repository.storeTask(parameters: syncTaskParams(id: "t1", jobType: .update, path: "a.mp3")) + try await repository.storeTask(parameters: syncTaskParams(id: "t2", jobType: .shallowDelete, path: "b.mp3")) + try await repository.storeTask(parameters: syncTaskParams(id: "t3", jobType: .delete, path: "c.mp3")) + + await repository.park(taskId: "t1", pause: pause(.task)) + var next = await repository.getNextTask(for: TaskQueueKey.sync) + XCTAssertEqual(next?.id, "t2", "a task-level pause lets the rest of the lane run") + + await repository.park(taskId: "t2", pause: pause(.lane)) + next = await repository.getNextTask(for: TaskQueueKey.sync) + XCTAssertNil(next, "a lane-level pause holds every later task") + + await repository.resume(taskId: "t2") + next = await repository.getNextTask(for: TaskQueueKey.sync) + XCTAssertEqual(next?.id, "t2") + } + + func testAccountPause_holdsEveryServerLane_butNotProviderLanes() async throws { + try await repository.storeTask(parameters: syncTaskParams(id: "s1", jobType: .shallowDelete, path: "a.mp3")) + try await repository.storeTask(parameters: syncTaskParams(id: "s2", jobType: .delete, path: "b.mp3")) + try await repository.storeTask(parameters: uploadFileParams(id: "u1")) + try await repository.storeTask(parameters: externalUpdateParams(id: "j1")) + + await repository.park(taskId: "s1", pause: pause(.account, code: "not_subscribed")) + await repository.park(taskId: "s2", pause: pause(.account, code: "not_subscribed")) + + let sync = await repository.getNextTask(for: TaskQueueKey.sync) + let upload = await repository.getNextTask(for: TaskQueueKey.uploadFile) + let push = await repository.getNextTask(for: "jellyfin") + XCTAssertNil(sync) + XCTAssertNil(upload) + XCTAssertEqual(push?.id, "j1") + + // One cause: resuming one account pause resumes them all + await repository.resume(taskId: "s1") + let resumedUpload = await repository.getNextTask(for: TaskQueueKey.uploadFile) + let resumedSync = await repository.getNextTask(for: TaskQueueKey.sync) + XCTAssertEqual(resumedUpload?.id, "u1") + XCTAssertEqual(resumedSync?.id, "s1") + XCTAssertNil(resumedSync?.pause) + } + + func testResumeAllPaused_keepsTheSentryEventId() async throws { + try await repository.storeTask(parameters: syncTaskParams(id: "t1", jobType: .shallowDelete, path: "a.mp3")) + await repository.park(taskId: "t1", pause: pause(.lane)) + await repository.setSentryEventId("evt-1", forTask: "t1") + + var stored = await repository.getAllTasks().first + XCTAssertEqual(stored?.pause?.errorCode, "item_not_found") + XCTAssertEqual(stored?.pause?.message, "msg") + XCTAssertEqual(stored?.pause?.httpStatus, 404) + XCTAssertEqual(stored?.pause?.sentryEventId, "evt-1") + + await repository.resumeAllPaused() + stored = await repository.getAllTasks().first + XCTAssertNil(stored?.pause) + + let reparked = await repository.park(taskId: "t1", pause: pause(.lane)) + XCTAssertEqual(reparked?.sentryEventId, "evt-1", "a re-park must not be reported again") + + let gone = await repository.park(taskId: "missing", pause: pause(.lane)) + XCTAssertNil(gone) + } + + /// New work never merges into a parked task (it would wait behind the failure); it lands + /// in a task of its own, which later updates may merge into as usual + func testCoalescing_skipsParkedTasks() async throws { + var first = syncTaskParams(id: "u1", jobType: .update, path: "a.mp3") + first["uuid"] = "book-1" + try await repository.storeTask(parameters: first) + await repository.park(taskId: "u1", pause: pause(.task)) + + var second = syncTaskParams(id: "u2", jobType: .update, path: "a.mp3") + second["uuid"] = "book-1" + try await repository.storeTask(parameters: second) + var third = syncTaskParams(id: "u3", jobType: .update, path: "a.mp3") + third["uuid"] = "book-1" + try await repository.storeTask(parameters: third) + + let ids = await repository.getAllTasks().map(\.id) + XCTAssertEqual(ids, ["u1", "u2"], "u2 stands apart from the parked u1; u3 merges into u2") + } + + /// A parked task behind the runnable head is still never a merge target + func testCoalescing_skipsAParkedTaskBehindTheHead() async throws { + var head = syncTaskParams(id: "h1", jobType: .update, path: "h.mp3") + head["uuid"] = "book-head" + try await repository.storeTask(parameters: head) + var parked = syncTaskParams(id: "p1", jobType: .update, path: "a.mp3") + parked["uuid"] = "book-1" + try await repository.storeTask(parameters: parked) + await repository.park(taskId: "p1", pause: pause(.task)) + + var newer = syncTaskParams(id: "p2", jobType: .update, path: "a.mp3") + newer["uuid"] = "book-1" + try await repository.storeTask(parameters: newer) + + let ids = await repository.getAllTasks().map(\.id) + XCTAssertEqual(ids, ["h1", "p1", "p2"]) + } + + /// A Retry puts the resumed task ahead of the one running: the running task's parameters + /// were already read, so merging into it would lose the new update + func testCoalescing_neverMergesIntoTheRunningTask_afterARetryReordersTheLane() async throws { + var parked = syncTaskParams(id: "r1", jobType: .update, path: "a.mp3") + parked["uuid"] = "book-1" + try await repository.storeTask(parameters: parked) + await repository.park(taskId: "r1", pause: pause(.task)) + var running = syncTaskParams(id: "r2", jobType: .update, path: "b.mp3") + running["uuid"] = "book-2" + try await repository.storeTask(parameters: running) + + let picked = await repository.getNextTask(for: TaskQueueKey.sync) + XCTAssertEqual(picked?.id, "r2") + await repository.resume(taskId: "r1") + + var newer = syncTaskParams(id: "r3", jobType: .update, path: "b.mp3") + newer["uuid"] = "book-2" + try await repository.storeTask(parameters: newer) + + let ids = await repository.getAllTasks().map(\.id) + XCTAssertEqual(ids, ["r1", "r2", "r3"]) + } + + func testQueueCounts_reportPausedAndBlockedLanes() async throws { + try await repository.storeTask(parameters: syncTaskParams(id: "t1", jobType: .update, path: "a.mp3")) + try await repository.storeTask(parameters: uploadFileParams(id: "u1")) + await repository.park(taskId: "t1", pause: pause(.task)) + + var counts = try await awaitCounts(from: tasksDataManager.observeQueueCounts()) { $0.totalPaused == 1 } + XCTAssertEqual(counts.pausedCount(in: TaskQueueKey.sync), 1) + XCTAssertFalse(counts.isBlocked(TaskQueueKey.sync)) + XCTAssertTrue(counts.isIdle(TaskQueueKey.sync), "only parked tasks left: nothing to wait for") + XCTAssertFalse(counts.isIdle(TaskQueueKey.uploadFile)) + + try await repository.storeTask(parameters: syncTaskParams(id: "t2", jobType: .shallowDelete, path: "b.mp3")) + await repository.park(taskId: "t2", pause: pause(.account, code: "not_subscribed")) + counts = try await awaitCounts(from: tasksDataManager.observeQueueCounts()) { $0.totalPaused == 2 } + XCTAssertTrue(counts.isBlocked(TaskQueueKey.sync)) + XCTAssertTrue(counts.isBlocked(TaskQueueKey.uploadFile)) + XCTAssertEqual(counts.count(in: TaskQueueKey.sync), 2) + } + + func testLaneDrained_treatsABlockedLaneAsDrained() { + let subject = CurrentValueSubject( + QueueCounts(byQueueKey: [TaskQueueKey.sync: 2]) + ) + var seen = [Bool]() + let subscription = subject.laneDrained(TaskQueueKey.sync).sink { seen.append($0) } + defer { subscription.cancel() } + + subject.send(QueueCounts(byQueueKey: [TaskQueueKey.sync: 2], pausedByQueueKey: [TaskQueueKey.sync: 1])) + subject.send(QueueCounts( + byQueueKey: [TaskQueueKey.sync: 2], + pausedByQueueKey: [TaskQueueKey.sync: 1], + blockedQueueKeys: [TaskQueueKey.sync] + )) + + XCTAssertEqual(seen, [false, false, true]) + } + + // MARK: Engine + + private func makeParkingEngine( + client: NetworkClientProtocol, + verify: @escaping () async -> Bool? = { true } + ) -> SyncQueueService { + let service = makeGatedEngine() + service.networkClient = client + service.verifySyncEntitlement = verify + return service + } + + private func waitForPause(of taskId: String, timeout: TimeInterval = 3) async throws -> TaskPause? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let pause = await repository.getAllTasks().first(where: { $0.id == taskId })?.pause { return pause } + try await Task.sleep(for: .milliseconds(20)) + } + XCTFail("timed out waiting for \(taskId) to park") + return nil + } + + /// A structural task that can never succeed stops its lane at once (no 5 s retry loop), + /// is reported once, and Retry runs it again + func testCodedFailure_parksAStructuralTask_holdsTheLane_andRetryRunsIt() async throws { + let client = FailingNetworkClient(errors: [coded("item_not_found")]) + let service = makeParkingEngine(client: client) + try await repository.storeTask(parameters: syncTaskParams(id: "e3m1", jobType: .delete, path: "a.mp3")) + try await repository.storeTask(parameters: syncTaskParams(id: "e3m2", jobType: .delete, path: "b.mp3")) + + let reported = expectation(forNotification: .syncTaskPaused, object: nil) { note in + (note.object as? QueuedSyncTask)?.id == "e3m1" + } + service.setServerLanesEnabled(true) + + let pause = try await waitForPause(of: "e3m1") + XCTAssertEqual(pause?.scope, .lane) + XCTAssertEqual(pause?.errorCode, "item_not_found") + XCTAssertEqual(pause?.httpStatus, 404) + await fulfillment(of: [reported], timeout: 2) + + try await Task.sleep(for: .milliseconds(300)) + XCTAssertEqual(client.requestCount, 1, "the lane waits behind the parked task") + + await service.retryPausedTask(id: "e3m1") + try await waitForEmptyQueue() + XCTAssertEqual(client.requestCount, 3) + } + + func testCodedFailure_onALeafTask_parksItAlone() async throws { + let client = FailingNetworkClient(errors: [coded("invalid_request", status: 400)]) + let service = makeParkingEngine(client: client) + try await repository.storeTask(parameters: syncTaskParams(id: "e4a1", jobType: .update, path: "a.mp3")) + try await repository.storeTask(parameters: syncTaskParams(id: "e4d1", jobType: .delete, path: "b.mp3")) + + service.setServerLanesEnabled(true) + try await waitForTaskCount(exactly: 1) + let left = await repository.getAllTasks() + XCTAssertEqual(left.map(\.id), ["e4a1"]) + XCTAssertEqual(left.first?.pause?.scope, .task) + } + + func testCodedFailure_withParkingDisabled_dropsTheTask() async throws { + let client = FailingNetworkClient(errors: [coded("item_not_found")]) + let service = makeParkingEngine(client: client) + service.parkingEnabled = false + try await repository.storeTask(parameters: syncTaskParams(id: "e5m1", jobType: .delete, path: "a.mp3")) + try await repository.storeTask(parameters: syncTaskParams(id: "e5m2", jobType: .delete, path: "b.mp3")) + + service.setServerLanesEnabled(true) + try await waitForEmptyQueue() + XCTAssertEqual(client.requestCount, 2) + } + + /// RevenueCat still says active: every server lane holds and the pause is reported + func testAccountRejection_whileRevenueCatSaysActive_holdsTheServerLanes_andReports() async throws { + let client = FailingNetworkClient(errors: [coded("not_subscribed", status: 400)]) + let service = makeParkingEngine(client: client, verify: { true }) + try await repository.storeTask(parameters: syncTaskParams(id: "e7m1", jobType: .delete, path: "a.mp3")) + + let reported = expectation(forNotification: .syncTaskPaused, object: nil) { note in + (note.object as? QueuedSyncTask)?.pause?.scope == .account + } + service.setServerLanesEnabled(true) + + let pause = try await waitForPause(of: "e7m1") + XCTAssertEqual(pause?.scope, .account) + await fulfillment(of: [reported], timeout: 2) + + // An upload that would otherwise run (its missing file is consumed without network) + var upload = uploadFileParams(id: "e7u1") + upload["filePath"] = URL(fileURLWithPath: "/nonexistent/\(UUID().uuidString).m4b").absoluteString + try await repository.storeTask(parameters: upload) + service.wakeUpWorkers() + try await Task.sleep(for: .milliseconds(300)) + let held = await repository.getAllTasks() + XCTAssertEqual(Set(held.map(\.id)), ["e7m1", "e7u1"], "the upload lane holds too") + let counts = try await awaitCounts(from: tasksDataManager.observeQueueCounts()) { + $0.count(in: TaskQueueKey.uploadFile) == 1 + } + XCTAssertTrue(counts.isBlocked(TaskQueueKey.uploadFile)) + } + + /// RevenueCat confirms the lapse: nothing is reported (the lapse path clears the lanes) + func testAccountRejection_confirmedInactive_isNotReported() async throws { + let client = FailingNetworkClient(errors: [coded("not_subscribed", status: 400)]) + let verified = expectation(description: "entitlement checked") + let service = makeParkingEngine(client: client, verify: { + verified.fulfill() + return false + }) + try await repository.storeTask(parameters: syncTaskParams(id: "e9m1", jobType: .delete, path: "a.mp3")) + + // Filtered on this test's task: the notification is global, and an earlier test's + // engine may still post late + let reported = expectation(forNotification: .syncTaskPaused, object: nil) { note in + (note.object as? QueuedSyncTask)?.id == "e9m1" + } + reported.isInverted = true + service.setServerLanesEnabled(true) + + await fulfillment(of: [verified], timeout: 2) + await fulfillment(of: [reported], timeout: 0.5) + } + + /// The one automatic retry: a launch resumes parked tasks before waking the lanes + func testLaunchWake_resumesParkedTasks() async throws { + let service = makeParkingEngine(client: FailingNetworkClient(errors: [])) + try await repository.storeTask(parameters: syncTaskParams(id: "e11m1", jobType: .delete, path: "a.mp3")) + await repository.park(taskId: "e11m1", pause: pause(.lane)) + service.setServerLanesEnabled(true) + try await Task.sleep(for: .milliseconds(300)) + let stillParked = await repository.getAllTasks() + XCTAssertEqual(stillParked.count, 1, "an ordinary wake leaves parked tasks alone") + + service.wakeUpWorkers(resumingPausedTasks: true) + try await waitForEmptyQueue() + } +} + +/// Throws the queued errors for the first requests, then answers `Empty` +private final class FailingNetworkClient: NetworkClientMock, @unchecked Sendable { + private let lock = NSLock() + private var errors: [Error] + private var _requestCount = 0 + + var requestCount: Int { lock.withLock { _requestCount } } + + init(errors: [Error]) { + self.errors = errors + super.init(mockedResponse: Empty()) + } + + override func request( + path: String, + method: HTTPMethod, + parameters: [String: Any]? + ) async throws -> T { + let error: Error? = lock.withLock { + _requestCount += 1 + return errors.isEmpty ? nil : errors.removeFirst() + } + if let error { throw error } + // swiftlint:disable:next force_cast + return Empty() as! T + } +} + +// MARK: - Multipart upload state and API + +extension SyncQueueTests { + /// A relaunch must resume the same S3 upload: what's saved comes back with the task + func testUploadState_roundTripsThroughTheTask() async throws { + try await repository.storeTask(parameters: uploadFileParams(id: "mp1")) + var loaded = await repository.getNextTask(for: TaskQueueKey.uploadFile) + XCTAssertEqual( + MultipartUploadState(parameters: loaded?.parameters ?? [:]), + MultipartUploadState(uploadId: nil, partSize: 0, fileSize: 0, restartCount: 0) + ) + + let state = MultipartUploadState( + uploadId: "upload-1", + partSize: 64 * 1024 * 1024, + fileSize: 6_000_000_000, + restartCount: 2 + ) + await repository.saveUploadState(state, forTask: "mp1") + loaded = await repository.getNextTask(for: TaskQueueKey.uploadFile) + XCTAssertEqual(MultipartUploadState(parameters: loaded?.parameters ?? [:]), state) + + // Forgetting the upload (before a fresh start) keeps the rest + var forgotten = state + forgotten.uploadId = nil + await repository.saveUploadState(forgotten, forTask: "mp1") + loaded = await repository.getNextTask(for: TaskQueueKey.uploadFile) + XCTAssertEqual(MultipartUploadState(parameters: loaded?.parameters ?? [:]), forgotten) + } + + func testMultipartRoutes_matchTheServerContract() { + let start = LibraryAPI.startUpload(uuid: "u", fileSize: 6_000_000_000, partSize: 67_108_864) + XCTAssertEqual(start.path, "/v1/library/upload/start") + XCTAssertEqual(start.method, .post) + XCTAssertEqual(start.parameters?["fileSize"] as? Int64, 6_000_000_000) + XCTAssertEqual(start.parameters?["partSize"] as? Int, 67_108_864) + + let urls = LibraryAPI.uploadPartURLs(uuid: "u", uploadId: "x", partNumbers: [1, 2]) + XCTAssertEqual(urls.path, "/v1/library/upload/parts") + XCTAssertEqual(urls.method, .post) + XCTAssertEqual(urls.parameters?["partNumbers"] as? [Int], [1, 2]) + + let parts = LibraryAPI.uploadedParts(uuid: "u", uploadId: "x") + XCTAssertEqual(parts.path, "/v1/library/upload/parts") + XCTAssertEqual(parts.method, .get) + + let complete = LibraryAPI.completeUpload(uuid: "u", uploadId: "x", partCount: 90, fileSize: 6_000_000_000) + XCTAssertEqual(complete.path, "/v1/library/upload/complete") + XCTAssertEqual(complete.parameters?["partCount"] as? Int, 90) + + let abort = LibraryAPI.abortUpload(uuid: "u", uploadId: "x") + XCTAssertEqual(abort.path, "/v1/library/upload/abort") + XCTAssertEqual(abort.method, .post) + } + + func testMultipartResponses_decode() throws { + let decoder = JSONDecoder() + let started = try decoder.decode( + StartUploadResponse.self, + from: Data(#"{"status":"started","uploadId":"x","partSize":67108864,"partCount":90}"#.utf8) + ) + XCTAssertEqual(started.status, .started) + XCTAssertEqual(started.partCount, 90) + + let exists = try decoder.decode(StartUploadResponse.self, from: Data(#"{"status":"exists"}"#.utf8)) + XCTAssertEqual(exists.status, .exists) + XCTAssertNil(exists.uploadId) + + let urls = try decoder.decode( + UploadPartURLsResponse.self, + from: Data(#"{"parts":[{"partNumber":3,"url":"https://s3/p3","expiresAt":1790000000}]}"#.utf8) + ) + XCTAssertEqual(urls.parts.first?.partNumber, 3) + + let uploaded = try decoder.decode( + UploadedPartsResponse.self, + from: Data(#"{"parts":[{"partNumber":1,"size":67108864}]}"#.utf8) + ) + XCTAssertEqual(uploaded.parts.first?.size, 67_108_864) + } +} + +// MARK: - Upload lane wiring + +extension SyncQueueTests { + /// End to end through the engine: the upload task's operation runs, its local refusal + /// reaches the parking policy, and the task parks alone with the app's own reason, + /// unreported (an expected limit, not a failure) + func testTooLargeUpload_parksTheTaskWithItsLocalReason() async throws { + let service = makeGatedEngine() + let reported = expectation(forNotification: .syncTaskPaused, object: nil) { note in + (note.object as? QueuedSyncTask)?.id == "huge" + } + reported.isInverted = true + let link = FileManager.default.temporaryDirectory.appendingPathComponent("huge-\(UUID().uuidString).m4b") + FileManager.default.createFile(atPath: link.path, contents: nil) + let handle = try FileHandle(forWritingTo: link) + try handle.truncate(atOffset: UInt64(FileUploadOperation.maxFileSize) + 1) + try handle.close() + defer { try? FileManager.default.removeItem(at: link) } + var params = uploadFileParams(id: "huge") + params["filePath"] = link.absoluteString + try await repository.storeTask(parameters: params) + + service.setServerLanesEnabled(true) + + let deadline = Date().addingTimeInterval(3) + var pause: TaskPause? + while Date() < deadline { + pause = await repository.getAllTasks().first?.pause + if pause != nil { break } + try await Task.sleep(for: .milliseconds(20)) + } + XCTAssertEqual(pause?.scope, .task) + XCTAssertEqual(pause?.errorCode, "file_too_large") + XCTAssertNil(pause?.httpStatus) + XCTAssertTrue(FileManager.default.fileExists(atPath: link.path), "the link stays for a Retry") + await fulfillment(of: [reported], timeout: 0.5) + } +} + +// MARK: - Sync-lane hand-offs to the upload lane + +extension SyncQueueTests { + private func syncableItem(uuid: String, mediaServer: Bool) throws -> SyncableItem { + let resources = mediaServer + ? #","externalResources":[{"providerName":"jellyfin","providerId":"jf-1","syncStatus":"stream"}]"# + : "" + let json = #"{"relativePath":"Book.m4b","originalFileName":"Book.m4b","title":"Book","isFinished":false,"type":2,"uuid":"\#(uuid)"\#(resources)}"# + return try JSONDecoder().decode(SyncableItem.self, from: Data(json.utf8)) + } + + /// The upload lane hit `item_not_found`: the book is registered again through the sync + /// lane (from its current state), and this upload task is gone + private func runHandBack(item: SyncableItem?) async throws -> [QueuedSyncTask] { + let service = makeGatedEngine() + // `start` answers item_not_found; everything after (the re-registration) just fails, + // so the new sync task stays queued for the assertions + service.networkClient = FailingNetworkClient( + errors: [BookPlayerError.networkErrorWithCode(message: "gone", code: "item_not_found", status: 404)] + + Array(repeating: URLError(.timedOut), count: 50) + ) + service.findSyncableItem = { _ in item } + let link = FileManager.default.temporaryDirectory.appendingPathComponent("handback-\(UUID().uuidString).m4b") + try Data("0123456789".utf8).write(to: link) + defer { try? FileManager.default.removeItem(at: link) } + var params = uploadFileParams(id: "lost") + params["filePath"] = link.absoluteString + params["uuid"] = item?.uuid ?? UUID().uuidString + try await repository.storeTask(parameters: params) + + service.setServerLanesEnabled(true) + + let deadline = Date().addingTimeInterval(3) + var tasks = [QueuedSyncTask]() + while Date() < deadline { + tasks = await repository.getAllTasks() + if !tasks.contains(where: { $0.id == "lost" }) { break } + try await Task.sleep(for: .milliseconds(20)) + } + service.setServerLanesEnabled(false) + return tasks + } + + func testUploadOfABookTheServerLost_registersItAgain() async throws { + let uuid = UUID().uuidString + let tasks = try await runHandBack(item: try syncableItem(uuid: uuid, mediaServer: false)) + + XCTAssertFalse(tasks.contains { $0.id == "lost" }) + XCTAssertEqual(tasks.map(\.jobType), [.upload]) + XCTAssertEqual(tasks.first?.uuid, uuid) + XCTAssertNil(tasks.first?.pause) + } + + /// A media-server book's re-registration carries `provider`, which never schedules the + /// file: the pipe job follows it so the file still goes up + func testUploadOfAMediaServerBookTheServerLost_alsoQueuesItsFile() async throws { + let tasks = try await runHandBack(item: try syncableItem(uuid: UUID().uuidString, mediaServer: true)) + + // The item, its media-server link (so `complete` has a resource to mark downloaded), + // then its file + XCTAssertEqual(tasks.map(\.jobType), [.upload, .externalResource, .externalResourceToDownload]) + } + + /// Re-registering didn't help (e.g. another uuid holds the key on the server): the second + /// item_not_found for the same book parks it instead of cycling PUT + start forever + func testSecondItemNotFound_forTheSameBook_parksInsteadOfCycling() async throws { + let service = makeGatedEngine() + let lost = BookPlayerError.networkErrorWithCode(message: "gone", code: "item_not_found", status: 404) + service.networkClient = FailingNetworkClient(errors: [lost, lost] + Array(repeating: URLError(.timedOut), count: 50)) + let uuid = UUID().uuidString + service.findSyncableItem = { _ in nil } + for id in ["first", "second"] { + let link = FileManager.default.temporaryDirectory.appendingPathComponent("cycle-\(id)-\(UUID().uuidString).m4b") + try Data("0123456789".utf8).write(to: link) + var params = uploadFileParams(id: id) + params["filePath"] = link.absoluteString + params["uuid"] = uuid + try await repository.storeTask(parameters: params) + } + + service.setServerLanesEnabled(true) + + let deadline = Date().addingTimeInterval(10) + var tasks = [QueuedSyncTask]() + while Date() < deadline { + tasks = await repository.getAllTasks() + if tasks.map(\.id) == ["second"], tasks.first?.pause != nil { break } + try await Task.sleep(for: .milliseconds(50)) + } + service.setServerLanesEnabled(false) + XCTAssertEqual(tasks.map(\.id), ["second"], "the first was handed back (dropped: gone locally), the second parks") + XCTAssertEqual(tasks.first?.pause?.errorCode, "item_not_found") + XCTAssertEqual(tasks.first?.pause?.scope, .task) + } + + /// A Retry asks for the upload again: the book is handed back once more although it + /// already was this session (gone locally here, so dropped) instead of parking again + func testRetry_ofALostUploadThatParked_registersItAgain() async throws { + let service = makeGatedEngine() + let lost = BookPlayerError.networkErrorWithCode(message: "gone", code: "item_not_found", status: 404) + service.networkClient = FailingNetworkClient( + errors: [lost, lost, lost] + Array(repeating: URLError(.timedOut), count: 50) + ) + let uuid = UUID().uuidString + service.findSyncableItem = { _ in nil } + for id in ["first", "second"] { + let link = FileManager.default.temporaryDirectory.appendingPathComponent("retry-\(id)-\(UUID().uuidString).m4b") + try Data("0123456789".utf8).write(to: link) + var params = uploadFileParams(id: id) + params["filePath"] = link.absoluteString + params["uuid"] = uuid + try await repository.storeTask(parameters: params) + } + + service.setServerLanesEnabled(true) + + var deadline = Date().addingTimeInterval(10) + var tasks = [QueuedSyncTask]() + while Date() < deadline { + tasks = await repository.getAllTasks() + if tasks.map(\.id) == ["second"], tasks.first?.pause != nil { break } + try await Task.sleep(for: .milliseconds(50)) + } + XCTAssertEqual(tasks.first?.pause?.errorCode, "item_not_found", "parked before the Retry") + + await service.retryPausedTask(id: "second") + + deadline = Date().addingTimeInterval(10) + while Date() < deadline { + tasks = await repository.getAllTasks() + if tasks.isEmpty || tasks.first?.pause != nil { break } + try await Task.sleep(for: .milliseconds(50)) + } + service.setServerLanesEnabled(false) + XCTAssertTrue(tasks.isEmpty, "handed back again (dropped: gone locally), not parked again") + } + + /// Only a media-server link marks a book as streamed: a Hardcover link has no file and + /// must not stop the book's own file from uploading + func testUploadJob_flagsOnlyMediaServerBooksAsProviderBacked() async throws { + let scheduler = SyncJobScheduler(tasksRepository: repository) + let hardcover = try JSONDecoder().decode(SyncableItem.self, from: Data( + #"{"relativePath":"Local.m4b","originalFileName":"Local.m4b","title":"Local","isFinished":false,"type":2,"uuid":"\#(UUID().uuidString)","externalResources":[{"providerName":"hardcover","providerId":"hc-1","syncStatus":"not_synced"}]}"#.utf8 + )) + let streamed = try syncableItem(uuid: UUID().uuidString, mediaServer: true) + + await scheduler.scheduleLibraryItemUploadJob(for: hardcover) + await scheduler.scheduleLibraryItemUploadJob(for: streamed) + + let jobs = await repository.getAllTasksWithParams(in: TaskQueueKey.sync) + XCTAssertEqual(jobs.count, 2) + XCTAssertNil(jobs.first { $0.relativePath == "Local.m4b" }?.parameters["provider"]) + XCTAssertEqual(jobs.first { $0.relativePath == "Book.m4b" }?.parameters["provider"] as? String, "jellyfin") + } + + func testUploadOfABookGoneEverywhere_isDropped() async throws { + let tasks = try await runHandBack(item: nil) + + XCTAssertTrue(tasks.isEmpty) + } + + /// A downloaded media-server book's file goes to S3 through the upload lane: the job's + /// result schedules the upload of the local file (no server call of its own) + func testMediaServerDownload_queuesTheUploadOfItsFile() async throws { + let name = "pipe-\(UUID().uuidString).m4b" + let fileURL = DataManager.getProcessedFolderURL().appendingPathComponent(name) + try FileManager.default.createDirectory(at: DataManager.getProcessedFolderURL(), withIntermediateDirectories: true) + try Data("0123456789".utf8).write(to: fileURL) + defer { try? FileManager.default.removeItem(at: fileURL) } + let uuid = UUID().uuidString + let operation = LibraryItemSyncOperation( + client: FailingNetworkClient(errors: []), + task: SyncTask( + id: "pipe", + uuid: uuid, + relativePath: name, + jobType: .externalResourceToDownload, + parameters: ["id": "pipe", "uuid": uuid, "relativePath": name] + ), + canUploadFiles: true + ) + + let done = expectation(description: "finished") + let observer = operation.observe(\.isFinished, options: [.initial, .new]) { op, _ in + if op.isFinished { done.fulfill() } + } + operation.start() + await fulfillment(of: [done], timeout: 3) + observer.invalidate() + + XCTAssertTrue(operation.didSucceed) + guard case .uploadMetadata(let result) = operation.results else { + return XCTFail("expected the file to be handed to the upload lane") + } + XCTAssertEqual(result.uuid, uuid) + XCTAssertEqual(URL(string: result.filePath)?.path, fileURL.path) + } +} + +// MARK: - Dismiss (too-large uploads only) + +extension SyncQueueTests { + func testDismiss_removesATooLargeUploadAndItsLink() async throws { + let service = makeGatedEngine() + let link = FileManager.default.temporaryDirectory.appendingPathComponent("dismiss-\(UUID().uuidString).m4b") + try Data("x".utf8).write(to: link) + defer { try? FileManager.default.removeItem(at: link) } + var params = uploadFileParams(id: "big") + params["filePath"] = link.absoluteString + try await repository.storeTask(parameters: params) + await repository.park( + taskId: "big", + pause: TaskPause(scope: .task, errorCode: "file_too_large", message: "m", httpStatus: nil, pausedAt: Date()) + ) + + service.dismissPausedTask(id: "big") + + try await waitForEmptyQueue() + // The link goes right after the pop, off the actor + let deadline = Date().addingTimeInterval(2) + while Date() < deadline, FileManager.default.fileExists(atPath: link.path) { + try await Task.sleep(for: .milliseconds(20)) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: link.path)) + } + + func testDismiss_ignoresATaskThatIsNotParked() async throws { + let service = makeGatedEngine() + try await repository.storeTask(parameters: uploadFileParams(id: "pending")) + + service.dismissPausedTask(id: "pending") + service.dismissPausedTask(id: "unknown") + try await Task.sleep(for: .milliseconds(300)) + + let tasks = await repository.getAllTasks() + XCTAssertEqual(tasks.map(\.id), ["pending"]) + } + + /// Server-refused tasks never get a Skip: Dismiss refuses them + func testDismiss_refusesAnyOtherPause() async throws { + let service = makeGatedEngine() + try await repository.storeTask(parameters: uploadFileParams(id: "refused")) + await repository.park( + taskId: "refused", + pause: TaskPause(scope: .task, errorCode: "invalid_parts", message: "m", httpStatus: 422, pausedAt: Date()) + ) + + service.dismissPausedTask(id: "refused") + try await Task.sleep(for: .milliseconds(300)) + + let tasks = await repository.getAllTasks() + XCTAssertEqual(tasks.map(\.id), ["refused"]) + } +} + +// MARK: - Settling uploads for a background wake + +extension SyncQueueTests { + /// Nothing will upload: the wake is answered right away + func testSettleUploads_returnsAtOnceWhenNothingWillRun() async throws { + let service = makeGatedEngine() + + let gatedStart = Date() + await service.settleUploads() + XCTAssertLessThan(Date().timeIntervalSince(gatedStart), 1, "sync off") + + service.setServerLanesEnabled(true) + let emptyStart = Date() + await service.settleUploads() + XCTAssertLessThan(Date().timeIntervalSince(emptyStart), 1, "nothing queued") + service.setServerLanesEnabled(false) + } +} + +// MARK: - Books waiting to upload (the continued task's view) + +extension SyncQueueTests { + func testPendingBookUploads_listsBothLanes_skippingFoldersAndParkedTasks() async throws { + let service = makeGatedEngine() + let link = FileManager.default.temporaryDirectory.appendingPathComponent("pending-\(UUID().uuidString).m4b") + try Data("0123456789".utf8).write(to: link) + defer { try? FileManager.default.removeItem(at: link) } + + var upload = uploadFileParams(id: "file") + upload["filePath"] = link.absoluteString + upload["uuid"] = "u-file" + upload["relativePath"] = "Uploading.m4b" + try await repository.storeTask(parameters: upload) + var parkedUpload = uploadFileParams(id: "parked") + parkedUpload["uuid"] = "u-parked" + try await repository.storeTask(parameters: parkedUpload) + await repository.park( + taskId: "parked", + pause: TaskPause(scope: .task, errorCode: "invalid_parts", message: "m", httpStatus: 422, pausedAt: Date()) + ) + let scheduler = SyncJobScheduler(tasksRepository: repository) + let book = try JSONDecoder().decode(SyncableItem.self, from: Data( + #"{"relativePath":"Next.m4b","originalFileName":"Next.m4b","title":"Next","isFinished":false,"type":2,"uuid":"u-book"}"#.utf8 + )) + let folder = try JSONDecoder().decode(SyncableItem.self, from: Data( + #"{"relativePath":"Folder","originalFileName":"Folder","title":"Folder","isFinished":false,"type":0,"uuid":"u-folder"}"#.utf8 + )) + // A media-server book: its metadata upload carries `provider` and no file follows + let streamed = try syncableItem(uuid: "u-streamed", mediaServer: true) + await scheduler.scheduleLibraryItemUploadJob(for: book) + await scheduler.scheduleLibraryItemUploadJob(for: folder) + await scheduler.scheduleLibraryItemUploadJob(for: streamed) + + let pending = await service.pendingBookUploads() + + // The sync lane first, then the upload lane + XCTAssertEqual(pending.books.map(\.uuid), ["u-book", "u-file"]) + XCTAssertEqual(pending.books.map(\.queueKey), [TaskQueueKey.sync, TaskQueueKey.uploadFile]) + XCTAssertEqual(pending.books.last?.fileSize, 10) + XCTAssertEqual(pending.books.last?.fileName, "Uploading.m4b") + XCTAssertEqual(pending.parkedCount, 1) + } +} + +// MARK: - The sync-to-upload hand-off + +/// Answers the metadata upload with a URL (the server needs the bytes) and fails everything +/// else, so the upload lane just retries +private final class NeedsBytesNetworkClient: NetworkClientMock, @unchecked Sendable { + init() { super.init(mockedResponse: Empty()) } + + override func request( + path: String, + method: HTTPMethod, + parameters: [String: Any]? + ) async throws -> T { + guard path == "/v1/library", method == .put else { throw URLError(.timedOut) } + let json = #"{"content":{"url":"https://s3.test/book"}}"# + return try JSONDecoder().decode(T.self, from: Data(json.utf8)) + } +} + +extension SyncQueueTests { + /// The upload task is stored before the sync `.upload` is popped: at no point is the book + /// in neither lane (a kill there would lose the upload; the continued task would count it + /// done) + func testMetadataUploadNeedingBytes_neverLeavesTheBookInNeitherLane() async throws { + let service = makeGatedEngine() + service.networkClient = NeedsBytesNetworkClient() + let name = "handoff-\(UUID().uuidString).m4b" + let bookURL = DataManager.getProcessedFolderURL().appendingPathComponent(name) + try FileManager.default.createDirectory(at: DataManager.getProcessedFolderURL(), withIntermediateDirectories: true) + try Data("0123456789".utf8).write(to: bookURL) + defer { + try? FileManager.default.removeItem(at: bookURL) + try? FileManager.default.removeItem(at: SyncJobScheduler.hardLinkURL(for: name)) + } + let book = try JSONDecoder().decode(SyncableItem.self, from: Data( + #"{"relativePath":"\#(name)","originalFileName":"\#(name)","title":"Book","isFinished":false,"type":2,"uuid":"\#(UUID().uuidString)"}"#.utf8 + )) + // Appended on main (the counts are delivered there), read there too + nonisolated(unsafe) var snapshots = [QueueCounts]() + let recording = tasksDataManager.observeQueueCounts().sink { snapshots.append($0) } + defer { recording.cancel() } + await SyncJobScheduler(tasksRepository: repository).scheduleLibraryItemUploadJob(for: book) + + service.setServerLanesEnabled(true) + + let deadline = Date().addingTimeInterval(3) + while Date() < deadline { + let tasks = await repository.getAllTasks() + if tasks.map(\.jobType) == [.uploadFile] { break } + try await Task.sleep(for: .milliseconds(20)) + } + service.setServerLanesEnabled(false) + try await Task.sleep(for: .milliseconds(50)) + let recorded = await MainActor.run { snapshots } + + XCTAssertEqual(recorded.last?.count(in: TaskQueueKey.uploadFile), 1) + XCTAssertEqual(recorded.last?.count(in: TaskQueueKey.sync), 0) + let firstQueued = recorded.firstIndex { $0.total > 0 } ?? 0 + XCTAssertFalse( + recorded[firstQueued...].contains { $0.total == 0 }, + "the book was briefly in neither lane" + ) + } +} + +// MARK: - Upload confirmation by tier + +/// Records each library request and answers the metadata upload with no URL +private final class NoURLLibraryClient: NetworkClientMock, @unchecked Sendable { + private let lock = NSLock() + private var _calls = [String]() + private var _lastParameters: [String: Any]? + + var calls: [String] { lock.withLock { _calls } } + var lastParameters: [String: Any]? { lock.withLock { _lastParameters } } + + init() { + super.init(mockedResponse: Empty()) + } + + override func request( + path: String, + method: HTTPMethod, + parameters: [String: Any]? + ) async throws -> T { + lock.withLock { + _calls.append("\(method.rawValue) \(path)") + _lastParameters = parameters + } + return try JSONDecoder().decode(T.self, from: Data(#"{"content":{"url":null}}"#.utf8)) + } +} + +extension SyncQueueTests { + private func runMetadataUpload(type: SimpleItemType, canUploadFiles: Bool) async throws -> NoURLLibraryClient { + let client = NoURLLibraryClient() + let uuid = UUID().uuidString + let operation = LibraryItemSyncOperation( + client: client, + task: SyncTask( + id: "meta", + uuid: uuid, + relativePath: "Tier-\(uuid).m4b", + jobType: .upload, + parameters: ["id": "meta", "uuid": uuid, "relativePath": "Tier-\(uuid).m4b", "type": type.rawValue] + ), + canUploadFiles: canUploadFiles + ) + + let done = expectation(description: "finished") + let observer = operation.observe(\.isFinished, options: [.initial, .new]) { op, _ in + if op.isFinished { done.fulfill() } + } + operation.start() + await fulfillment(of: [done], timeout: 3) + observer.invalidate() + + XCTAssertTrue(operation.didSucceed) + return client + } + + /// A book's `synced` means its file is in S3. Without S3 access, no URL only means the tier + /// stores no file, so the book stays unconfirmed and its file can go up after an upgrade + func testMetadataUploadWithoutS3Access_neverConfirmsABook() async throws { + let client = try await runMetadataUpload(type: .book, canUploadFiles: false) + + XCTAssertEqual(client.calls, ["PUT /v1/library"]) + } + + /// With S3 access, no URL means the file is already there: the book is confirmed + func testMetadataUploadWithS3Access_confirmsABookAlreadyStored() async throws { + let client = try await runMetadataUpload(type: .book, canUploadFiles: true) + + XCTAssertEqual(client.calls, ["PUT /v1/library", "POST /v1/library"]) + XCTAssertEqual(client.lastParameters?["synced"] as? Bool, true) + } + + /// Folders and bound books aren't confirmed by a file: they're confirmed on every tier + func testMetadataUploadWithoutS3Access_stillConfirmsFoldersAndBoundBooks() async throws { + for type in [SimpleItemType.folder, .bound] { + let client = try await runMetadataUpload(type: type, canUploadFiles: false) + + XCTAssertEqual(client.calls, ["PUT /v1/library", "POST /v1/library"], "\(type)") + XCTAssertEqual(client.lastParameters?["synced"] as? Bool, true, "\(type)") + } + } + + /// The queue hands its tier to the operation it builds: a book queued on a tier without + /// S3 access is registered but never confirmed, and one queued on PRO is + func testQueuedMetadataUpload_confirmsABookOnlyWithS3Access() async throws { + for canUploadFiles in [false, true] { + let service = makeGatedEngine() + service.accessPolicy = [.uploadFile: canUploadFiles, .externalUpdate: true] + let client = NoURLLibraryClient() + service.networkClient = client + let uuid = UUID().uuidString + let book = try JSONDecoder().decode(SyncableItem.self, from: Data( + #"{"relativePath":"Tier-\#(uuid).m4b","originalFileName":"Tier.m4b","title":"Tier","isFinished":false,"type":2,"uuid":"\#(uuid)"}"#.utf8 + )) + await SyncJobScheduler(tasksRepository: repository).scheduleLibraryItemUploadJob(for: book) + + service.setServerLanesEnabled(true) + try await waitForEmptyQueue() + service.setServerLanesEnabled(false) + + XCTAssertEqual( + client.calls, + canUploadFiles ? ["PUT /v1/library", "POST /v1/library"] : ["PUT /v1/library"], + "canUploadFiles: \(canUploadFiles)" + ) + } + } +} diff --git a/BookPlayerTests/Services/SyncTasksStorageTests.swift b/BookPlayerTests/Services/SyncTasksStorageTests.swift index 8e50373cc..2ae4fb1a4 100644 --- a/BookPlayerTests/Services/SyncTasksStorageTests.swift +++ b/BookPlayerTests/Services/SyncTasksStorageTests.swift @@ -13,14 +13,12 @@ import XCTest @testable import BookPlayer @testable import BookPlayerKit -final class SyncTasksStorageTests: XCTestCase { +final class SyncTasksRepositoryTests: XCTestCase { private var tasksDataManager: TasksDataManager! - private var storage: SyncTasksStorage! + private var repository: SyncQueueRepository! override func setUpWithError() throws { let schema = Schema([ - SyncTasksContainer.self, - SyncTaskReferenceModel.self, UploadTaskModel.self, UpdateTaskModel.self, MoveTaskModel.self, @@ -29,46 +27,42 @@ final class SyncTasksStorageTests: XCTestCase { SetBookmarkTaskModel.self, RenameFolderTaskModel.self, ArtworkUploadTaskModel.self, - MatchUuidsTaskModel.self + MatchUuidsTaskModel.self, + UploadExternalResourceTaskModel.self, + ExternalResourceToDownloadTaskModel.self, + DeleteExternalResourceTaskModel.self, + SyncQueueContainer.self, + QueuedTaskReferenceModel.self, + ExternalUpdateTaskModel.self, + UploadFileTaskModel.self, ]) let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none) let container = try ModelContainer(for: schema, configurations: config) tasksDataManager = TasksDataManager(container: container) - storage = try SyncTasksStorage(tasksDataManager: tasksDataManager) + repository = SyncQueueRepository(tasksDataManager: tasksDataManager) } override func tearDown() { - storage = nil + repository = nil tasksDataManager = nil super.tearDown() } - /// Progress lookup must key on `uuid`, so a progress dictionary indexed by uuid - /// surfaces correctly on the returned SyncTaskReference. - func testGetAllTasks_progressKeyedByUuid_returnsProgressForTask() async throws { - let uuid = "item-uuid-1" + /// A stored task surfaces the uuid and path the row keys its progress on; with a real + /// uuid the key IS the uuid (a path-keyed progress update must not match it). + func testGetAllTasks_realUuid_progressKeyIsTheUuid() async throws { + let uuid = UUID().uuidString let relativePath = "Folder/Book.mp3" try await appendUploadTask(uuid: uuid, relativePath: relativePath) - let references = await storage.getAllTasks(progress: [uuid: 0.42]) + let tasks = await repository.getAllTasks() - XCTAssertEqual(references.count, 1) - let reference = try XCTUnwrap(references.first) - XCTAssertEqual(reference.uuid, uuid) - XCTAssertEqual(reference.progress, 0.42, accuracy: 0.0001) - } - - /// Regression guard: with a real uuid, a progress dict keyed by relativePath must NOT resolve. - func testGetAllTasks_progressKeyedByRelativePath_returnsZero() async throws { - let uuid = "item-uuid-2" - let relativePath = "Folder/Other.mp3" - try await appendUploadTask(uuid: uuid, relativePath: relativePath) - - let references = await storage.getAllTasks(progress: [relativePath: 0.75]) - - XCTAssertEqual(references.count, 1) - let reference = try XCTUnwrap(references.first) - XCTAssertEqual(reference.progress, 0.0) + XCTAssertEqual(tasks.count, 1) + let task = try XCTUnwrap(tasks.first) + XCTAssertEqual(task.uuid, uuid) + XCTAssertEqual(task.relativePath, relativePath) + XCTAssertEqual(task.progressKey, uuid) + XCTAssertNotEqual(task.progressKey, relativePath) } /// Legacy-migrated task references share the same placeholder uuid; the progress key @@ -77,11 +71,10 @@ final class SyncTasksStorageTests: XCTestCase { let relativePath = "Folder/Legacy.mp3" try await appendUploadTask(uuid: Constants.legacyUuidPlaceholder, relativePath: relativePath) - let references = await storage.getAllTasks(progress: [relativePath: 0.6]) + let tasks = await repository.getAllTasks() - XCTAssertEqual(references.count, 1) - let reference = try XCTUnwrap(references.first) - XCTAssertEqual(reference.progress, 0.6, accuracy: 0.0001) + XCTAssertEqual(tasks.count, 1) + XCTAssertEqual(tasks.first?.progressKey, relativePath) } /// Same fallback applies to the local placeholder used as the schema default. @@ -89,11 +82,10 @@ final class SyncTasksStorageTests: XCTestCase { let relativePath = "Folder/Local.mp3" try await appendUploadTask(uuid: Constants.uuidPlaceholder, relativePath: relativePath) - let references = await storage.getAllTasks(progress: [relativePath: 0.33]) + let tasks = await repository.getAllTasks() - XCTAssertEqual(references.count, 1) - let reference = try XCTUnwrap(references.first) - XCTAssertEqual(reference.progress, 0.33, accuracy: 0.0001) + XCTAssertEqual(tasks.count, 1) + XCTAssertEqual(tasks.first?.progressKey, relativePath) } /// `matchUuid` conflicts must rewrite both the task reference and the underlying task @@ -104,13 +96,13 @@ final class SyncTasksStorageTests: XCTestCase { let relativePath = "Folder/Swap.mp3" try await appendUploadTask(uuid: oldUuid, relativePath: relativePath) - try await storage.applyMatchUuidConflicts([ItemConflict(key: oldUuid, uuid: newUuid)]) + try await repository.applyMatchUuidConflicts([ItemConflict(key: oldUuid, uuid: newUuid)]) - let refs = await storage.getAllTasks(progress: [:]) + let refs = await repository.getAllTasks() XCTAssertEqual(refs.count, 1) XCTAssertEqual(refs.first?.uuid, newUuid) - let tasks = await storage.getAllTasksWithParams() + let tasks = await repository.getAllTasksWithParams(in: TaskQueueKey.sync) XCTAssertEqual(tasks.count, 1) XCTAssertEqual(tasks.first?.uuid, newUuid) } @@ -120,9 +112,9 @@ final class SyncTasksStorageTests: XCTestCase { let uuid = "existing-uuid" try await appendUploadTask(uuid: uuid, relativePath: "Folder/Other.mp3") - try await storage.applyMatchUuidConflicts([ItemConflict(key: "ghost-uuid", uuid: "never")]) + try await repository.applyMatchUuidConflicts([ItemConflict(key: "ghost-uuid", uuid: "never")]) - let refs = await storage.getAllTasks(progress: [:]) + let refs = await repository.getAllTasks() XCTAssertEqual(refs.first?.uuid, uuid) } @@ -136,10 +128,10 @@ final class SyncTasksStorageTests: XCTestCase { // and introduces a new key (should be added). try await appendMatchUuidTask(uuids: ["folder/A.mp3": "uuid-A-prime", "folder/C.mp3": "uuid-C"]) - let refs = await storage.getAllTasks(progress: [:]) + let refs = await repository.getAllTasks() XCTAssertEqual(refs.filter { $0.jobType == .matchUuid }.count, 1) - let tasks = await storage.getAllTasksWithParams() + let tasks = await repository.getAllTasksWithParams(in: TaskQueueKey.sync) let matchTask = try XCTUnwrap(tasks.first(where: { $0.jobType == .matchUuid })) let mergedUuids = try XCTUnwrap(matchTask.parameters["uuids"] as? [String: String]) XCTAssertEqual(mergedUuids["folder/A.mp3"], "uuid-A") // existing preserved @@ -148,11 +140,69 @@ final class SyncTasksStorageTests: XCTestCase { XCTAssertEqual(mergedUuids.count, 3) } + /// The server rejects a /uuids request over its per-request limit forever, so the + /// scheduler splits a large listing into limit-sized tasks. + func testScheduleMatchUuids_overTheLimit_splitsIntoServerSizedTasks() async throws { + let scheduler = SyncJobScheduler(tasksRepository: repository) + let limit = SyncJobScheduler.matchUuidsBatchLimit + let uuids = Dictionary(uniqueKeysWithValues: (0..<(limit * 2 + limit / 2)).map { ("folder/\($0).mp3", "uuid-\($0)") }) + + await scheduler.scheduleMatchUuidsJob(uuidsDict: uuids) + + let tasks = await repository.getAllTasksWithParams(in: TaskQueueKey.sync) + .filter { $0.jobType == .matchUuid } + let sizes = tasks.compactMap { ($0.parameters["uuids"] as? [String: String])?.count } + XCTAssertEqual(sizes, [limit, limit, limit / 2]) + let all = tasks.compactMap { $0.parameters["uuids"] as? [String: String] } + .reduce(into: [String: String]()) { $0.merge($1) { first, _ in first } } + XCTAssertEqual(all, uuids) + } + + /// Merging into a queued matchUuid task must not grow it past the server's limit. + func testAppendMatchUuidTask_mergeWouldExceedTheLimit_queuesASeparateTask() async throws { + let limit = SyncJobScheduler.matchUuidsBatchLimit + try await appendUploadTask(uuid: "upload-uuid", relativePath: "folder/Head.mp3") + try await appendMatchUuidTask(uuids: Dictionary(uniqueKeysWithValues: (0..<(limit - 10)).map { ("a/\($0).mp3", "a-\($0)") })) + try await appendMatchUuidTask(uuids: Dictionary(uniqueKeysWithValues: (0..<20).map { ("b/\($0).mp3", "b-\($0)") })) + + let tasks = await repository.getAllTasksWithParams(in: TaskQueueKey.sync) + .filter { $0.jobType == .matchUuid } + XCTAssertEqual(tasks.compactMap { ($0.parameters["uuids"] as? [String: String])?.count }, [limit - 10, 20]) + } + + /// Only temp-directory files are ever removed as upload hard links. + func testRemoveHardLink_deletesOnlyInsideTheTempDirectory() throws { + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("link-\(UUID().uuidString).mp3") + let outside = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("real-\(UUID().uuidString).mp3") + try Data("x".utf8).write(to: tempFile) + try Data("x".utf8).write(to: outside) + defer { try? FileManager.default.removeItem(at: outside) } + + // A folder item's link mirrors its tree; its children's uploads still read inside it. + let tempFolder = FileManager.default.temporaryDirectory.appendingPathComponent("folder-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempFolder, withIntermediateDirectories: true) + let child = tempFolder.appendingPathComponent("Book.mp3") + try Data("x".utf8).write(to: child) + defer { try? FileManager.default.removeItem(at: tempFolder) } + + SyncJobScheduler.removeHardLink(at: tempFile) + SyncJobScheduler.removeHardLink(at: outside) + SyncJobScheduler.removeHardLink(at: nil) + SyncJobScheduler.removeHardLink(at: tempFolder) + SyncJobScheduler.removeHardLink(at: FileManager.default.temporaryDirectory) + + XCTAssertFalse(FileManager.default.fileExists(atPath: tempFile.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: outside.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: child.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: FileManager.default.temporaryDirectory.path)) + } + /// With no prior matchUuid task, the first call should create one normally. func testAppendMatchUuidTask_noExisting_createsNewTask() async throws { try await appendMatchUuidTask(uuids: ["folder/A.mp3": "uuid-A"]) - let refs = await storage.getAllTasks(progress: [:]) + let refs = await repository.getAllTasks() XCTAssertEqual(refs.count, 1) XCTAssertEqual(refs.first?.jobType, .matchUuid) } @@ -163,15 +213,15 @@ final class SyncTasksStorageTests: XCTestCase { func testAppendMatchUuidTask_firstInFlight_queuesSecondAndMergesIntoIt() async throws { try await appendMatchUuidTask(uuids: ["folder/A.mp3": "uuid-A"]) - // Simulate the first task being in flight — the scheduler calls getNextTask() + // Simulate the first task being in flight — the worker calls getNextTask(for:) // to hand the snapshot to the operation queue; the task stays in the store. - let inFlight = try await storage.getNextTask() + let inFlight = await repository.getNextTask(for: TaskQueueKey.sync) XCTAssertEqual(inFlight?.jobType, .matchUuid) // A second schedule must bypass the in-flight task and create a fresh one. try await appendMatchUuidTask(uuids: ["folder/B.mp3": "uuid-B"]) - let refsAfterSecond = await storage.getAllTasks(progress: [:]) + let refsAfterSecond = await repository.getAllTasks() let matchRefsAfterSecond = refsAfterSecond.filter { $0.jobType == .matchUuid } XCTAssertEqual(matchRefsAfterSecond.count, 2) @@ -179,7 +229,7 @@ final class SyncTasksStorageTests: XCTestCase { // leaving the in-flight task's dict unchanged. try await appendMatchUuidTask(uuids: ["folder/C.mp3": "uuid-C"]) - let tasksAfterThird = await storage.getAllTasksWithParams() + let tasksAfterThird = await repository.getAllTasksWithParams(in: TaskQueueKey.sync) let matchTasks = tasksAfterThird.filter { $0.jobType == .matchUuid } XCTAssertEqual(matchTasks.count, 2) @@ -192,15 +242,80 @@ final class SyncTasksStorageTests: XCTestCase { XCTAssertEqual(queuedUuids, ["folder/B.mp3": "uuid-B", "folder/C.mp3": "uuid-C"]) } + /// Sync tasks and concurrent (provider) tasks share the container: the display list spans + /// every lane in stored order, while counts and getNextTask stay per queue key. + func testQueueIsolation_syncAndProviderQueues() async throws { + try await appendUploadTask(uuid: "sync-uuid", relativePath: "Folder/Book.mp3") + try await repository.storeTask(parameters: [ + "id": UUID().uuidString, + "jobType": SyncJobType.externalUpdate.rawValue, + "queueKey": "jellyfin", + "providerName": "jellyfin", + "providerId": "provider-item-1", + "currentTime": 10.0, + "percentCompleted": 5.0 + ]) + + let syncCount = await repository.getTasksCount(in: TaskQueueKey.sync) + XCTAssertEqual(syncCount, 1) + let providerCount = await repository.getTasksCount(in: "jellyfin") + XCTAssertEqual(providerCount, 1) + + let allTasks = await repository.getAllTasks() + XCTAssertEqual(allTasks.map(\.jobType), [.upload, .externalUpdate]) + XCTAssertEqual(allTasks.map(\.queueKey), [TaskQueueKey.sync, "jellyfin"]) + + let nextProviderTask = await repository.getNextTask(for: "jellyfin") + XCTAssertEqual(nextProviderTask?.jobType, .externalUpdate) + let nextSyncTask = await repository.getNextTask(for: TaskQueueKey.sync) + XCTAssertEqual(nextSyncTask?.jobType, .upload) + } + + /// Popping a task must delete both the reference and its payload model. + func testPop_removesReferenceAndPayload() async throws { + try await appendUploadTask(uuid: "pop-uuid", relativePath: "Folder/Pop.mp3") + + let nextTask = await repository.getNextTask(for: TaskQueueKey.sync) + let task = try XCTUnwrap(nextTask) + await repository.pop(task) + + let count = await repository.getTasksCount(in: TaskQueueKey.sync) + XCTAssertEqual(count, 0) + let hasUpload = await repository.hasUploadTask(for: "Folder/Pop.mp3") + XCTAssertFalse(hasUpload) + } + + /// Clearing one queue must not touch the others. + func testClearAll_inQueue_leavesOtherQueuesUntouched() async throws { + try await appendUploadTask(uuid: "sync-uuid", relativePath: "Folder/Book.mp3") + try await repository.storeTask(parameters: [ + "id": UUID().uuidString, + "jobType": SyncJobType.externalUpdate.rawValue, + "queueKey": "jellyfin", + "providerName": "jellyfin", + "providerId": "provider-item-1", + "currentTime": 10.0, + "percentCompleted": 5.0 + ]) + + try await repository.clearAll(in: TaskQueueKey.sync) + + let syncCount = await repository.getTasksCount(in: TaskQueueKey.sync) + XCTAssertEqual(syncCount, 0) + let providerCount = await repository.getTasksCount(in: "jellyfin") + XCTAssertEqual(providerCount, 1) + } + private func appendMatchUuidTask(uuids: [String: String]) async throws { let parameters: [String: Any] = [ "id": UUID().uuidString, "jobType": SyncJobType.matchUuid.rawValue, + "queueKey": TaskQueueKey.sync, "relativePath": "", "uuid": "", "uuids": uuids ] - try await storage.appendTask(parameters: parameters) + try await repository.storeTask(parameters: parameters) } private func appendUploadTask(uuid: String, relativePath: String) async throws { @@ -217,8 +332,9 @@ final class SyncTasksStorageTests: XCTestCase { "isFinished": false, "orderRank": 0, "type": SimpleItemType.book.rawValue, - "jobType": SyncJobType.upload.rawValue + "jobType": SyncJobType.upload.rawValue, + "queueKey": TaskQueueKey.sync ] - try await storage.appendTask(parameters: parameters) + try await repository.storeTask(parameters: parameters) } } diff --git a/BookPlayerTests/Services/TasksDataManagerStoreTests.swift b/BookPlayerTests/Services/TasksDataManagerStoreTests.swift new file mode 100644 index 000000000..d908f706d --- /dev/null +++ b/BookPlayerTests/Services/TasksDataManagerStoreTests.swift @@ -0,0 +1,113 @@ +// +// TasksDataManagerStoreTests.swift +// BookPlayerTests +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import CoreData +import SwiftData +import XCTest + +@testable import BookPlayerKit + +/// A model no schema in `MigrationPlan` knows — what a store written between schema edits +/// (or by a newer build) looks like to this build. +@Model +final class AlienQueueModel { + var id: String + + init(id: String) { + self.id = id + } +} + +/// The launch-time store guard. Its previous form matched Cocoa error codes on the thrown +/// error, which SwiftData wraps opaquely, so it never fired and an unknown store crash-looped +/// the app at launch. The decision now comes from the store's own metadata, up front. +final class TasksDataManagerStoreTests: XCTestCase { + private var storeURL: URL! + + override func setUpWithError() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + storeURL = directory.appendingPathComponent("bp-synctasks.sqlite") + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: storeURL.deletingLastPathComponent()) + } + + /// Writes an on-disk store for `schema` and releases it. + private func writeStore(_ schema: Schema) throws { + let container = try ModelContainer( + for: schema, + configurations: ModelConfiguration(url: storeURL, cloudKitDatabase: .none) + ) + let context = ModelContext(container) + try context.save() + XCTAssertTrue(FileManager.default.fileExists(atPath: storeURL.path)) + } + + // MARK: - Detection + + func testCurrentSchemaStore_isKnownToThePlan() throws { + try writeStore(Schema(versionedSchema: SchemaV3.self)) + + XCTAssertFalse(TasksDataManager.storeIsUnknownToMigrationPlan(at: storeURL)) + } + + /// An older, migratable store must never be set aside — the plan can carry it forward. + func testOlderSchemaStore_isKnownToThePlan() throws { + try writeStore(Schema(versionedSchema: SchemaV1.self)) + + XCTAssertFalse(TasksDataManager.storeIsUnknownToMigrationPlan(at: storeURL)) + } + + func testAlienSchemaStore_isUnknownToThePlan() throws { + try writeStore(Schema([AlienQueueModel.self])) + + XCTAssertTrue(TasksDataManager.storeIsUnknownToMigrationPlan(at: storeURL)) + } + + func testMissingStore_isNotUnknown() { + XCTAssertFalse(TasksDataManager.storeIsUnknownToMigrationPlan(at: storeURL)) + } + + // MARK: - Launch path + + /// The crash the guard existed for and never caught: an unknown store is set aside (kept as + /// `.incompatible`) and a fresh container opens. + func testInit_setsAsideAnAlienStoreAndOpensFresh() throws { + try writeStore(Schema([AlienQueueModel.self])) + + let manager = TasksDataManager(storeURL: storeURL) + + XCTAssertTrue(FileManager.default.fileExists(atPath: storeURL.path + ".incompatible"), "kept, not deleted") + XCTAssertTrue(FileManager.default.fileExists(atPath: storeURL.path), "a fresh store replaces it") + let context = ModelContext(manager.container) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 0) + } + + func testInit_keepsACurrentStore() throws { + try writeStore(Schema(versionedSchema: SchemaV3.self)) + + _ = TasksDataManager(storeURL: storeURL) + + XCTAssertFalse(FileManager.default.fileExists(atPath: storeURL.path + ".incompatible")) + } + + /// Only one set-aside generation survives, so a repeated mismatch cannot pile up copies. + func testSetAsideStore_replacesThePreviousGeneration() throws { + try writeStore(Schema([AlienQueueModel.self])) + TasksDataManager.setAsideStore(at: storeURL) + XCTAssertFalse(FileManager.default.fileExists(atPath: storeURL.path)) + + try writeStore(Schema([AlienQueueModel.self])) + TasksDataManager.setAsideStore(at: storeURL) + + XCTAssertTrue(FileManager.default.fileExists(atPath: storeURL.path + ".incompatible")) + XCTAssertFalse(FileManager.default.fileExists(atPath: storeURL.path)) + } +} diff --git a/BookPlayerTests/Services/UploadContinuationTests.swift b/BookPlayerTests/Services/UploadContinuationTests.swift new file mode 100644 index 000000000..31a24c2af --- /dev/null +++ b/BookPlayerTests/Services/UploadContinuationTests.swift @@ -0,0 +1,464 @@ +// +// UploadContinuationTests.swift +// BookPlayerTests +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BackgroundTasks +import Combine +import Foundation +import XCTest + +@testable import BookPlayer +@testable import BookPlayerKit + +private final class FakeContinuedTask: ContinuedUploadTask { + let progress = Progress(totalUnitCount: 0) + var expirationHandler: (() -> Void)? + private(set) var titles = [(String, String)]() + private(set) var completions = [Bool]() + + func updateTitle(_ title: String, subtitle: String) { titles.append((title, subtitle)) } + func setTaskCompleted(success: Bool) { completions.append(success) } +} + +@MainActor +final class UploadContinuationTests: XCTestCase { + private func book(_ uuid: String, _ name: String, _ size: Int64) -> PendingBookUpload { + PendingBookUpload(uuid: uuid, relativePath: "Folder/\(name)", fileSize: size) + } + + // MARK: - Progress + + func testProgress_countsBytesPositionAndTheCurrentFile() { + var progress = UploadContinuationProgress() + progress.update(waiting: [book("a", "A.m4b", 100), book("b", "B.m4b", 300)]) + + XCTAssertEqual(progress.position.index, 1) + XCTAssertEqual(progress.position.count, 2) + XCTAssertEqual(progress.currentFileName, "A.m4b", "the next one waiting before any upload runs") + XCTAssertEqual(progress.totalBytes, 400) + XCTAssertEqual(progress.completedBytes, 0) + + progress.updateProgress(uuid: "a", fraction: 0.5) + XCTAssertEqual(progress.completedBytes, 50) + + // A finished; B is being sent + progress.update(waiting: [book("b", "B.m4b", 300)]) + progress.updateProgress(uuid: "b", fraction: 0.1) + XCTAssertEqual(progress.position.index, 2) + XCTAssertEqual(progress.currentFileName, "B.m4b") + XCTAssertEqual(progress.completedBytes, 130) + XCTAssertFalse(progress.isFinished) + + progress.update(waiting: []) + XCTAssertTrue(progress.isFinished) + XCTAssertEqual(progress.completedBytes, 400) + } + + /// Books queued mid-run join the count instead of starting a new one + func testProgress_growsWhenBooksAreQueuedMidRun() { + var progress = UploadContinuationProgress() + progress.update(waiting: [book("a", "A.m4b", 100)]) + progress.update(waiting: []) + progress.update(waiting: [book("c", "C.m4b", 50)]) + + XCTAssertEqual(progress.position.index, 2) + XCTAssertEqual(progress.position.count, 2) + XCTAssertEqual(progress.totalBytes, 150) + XCTAssertEqual(progress.currentFileName, "C.m4b") + } + + func testSubtitle_prefixesThePositionToTheFileName() { + var progress = UploadContinuationProgress() + progress.update(waiting: [book("a", "A.m4b", 1), book("b", "B.m4b", 1), book("c", "C.m4b", 1)]) + progress.update(waiting: [book("b", "B.m4b", 1), book("c", "C.m4b", 1)]) + + XCTAssertEqual(UploadContinuationController.subtitle(for: progress), "(2 / 3) B.m4b") + XCTAssertEqual(UploadContinuationController.title, "Uploading files") + } + + // MARK: - Controller + + private var pending = [PendingBookUpload]() + private var parkedCount = 0 + private var submitted = [BGTaskRequest]() + private var submitError: Error? + private var canUpload = true + private var networkAllows = true + private var foreground = true + private var requestPending = true + private let changes = PassthroughSubject() + /// What the queue reports right now (the throttled subscription may lag) + private var currentCounts = QueueCounts() + + private func dependencies() -> UploadContinuationController.Dependencies { + .init( + canUpload: { [unowned self] in self.canUpload }, + networkAllowsUploads: { [unowned self] in self.networkAllows }, + isForeground: { [unowned self] in self.foreground }, + pendingUploads: { [unowned self] in PendingBookUploads(books: self.pending, parkedCount: self.parkedCount) }, + submit: { [unowned self] request in + if let submitError = self.submitError { throw submitError } + self.submitted.append(request) + }, + hasPendingRequest: { [unowned self] in self.requestPending }, + queueChanges: { [unowned self] in self.changes.eraseToAnyPublisher() }, + currentCounts: { [unowned self] in self.currentCounts }, + confirmEmptyAfter: .milliseconds(50) + ) + } + + private func makeController() -> UploadContinuationController { + let controller = UploadContinuationController() + controller.setup(dependencies: dependencies()) + return controller + } + + private func waitUntil(_ timeout: TimeInterval = 2, _ condition: () -> Bool) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline, !condition() { + try await Task.sleep(for: .milliseconds(10)) + } + } + + func testSubmit_whenBooksWait_submitsOneRequestAndStarts() async { + pending = [book("a", "A.m4b", 100)] + let controller = makeController() + + await controller.submitIfNeeded() + await controller.submitIfNeeded() + + XCTAssertEqual(submitted.count, 1, "one continued task covers the whole queue") + XCTAssertEqual(submitted.first?.identifier, UploadContinuationController.identifier) + XCTAssertEqual(controller.state, .starting) + } + + func testSubmit_isHeldBackWhenItCantHelp() async { + let controller = makeController() + + // Nothing waiting + await controller.submitIfNeeded() + pending = [book("a", "A.m4b", 100)] + // No S3 access / sync off + canUpload = false + await controller.submitIfNeeded() + canUpload = true + // Wi-Fi only, on cellular + networkAllows = false + await controller.submitIfNeeded() + networkAllows = true + // iOS only accepts it from the foreground + foreground = false + await controller.submitIfNeeded() + + XCTAssertTrue(submitted.isEmpty) + XCTAssertEqual(controller.state, .idle) + } + + func testSubmit_refused_returnsToIdle() async { + pending = [book("a", "A.m4b", 100)] + submitError = URLError(.unknown) + let controller = makeController() + + await controller.submitIfNeeded() + + XCTAssertEqual(controller.state, .idle) + } + + /// iOS accepted a request but dropped it: the next queued books submit again + func testSubmit_afterIOSDroppedTheRequest_submitsAgain() async { + pending = [book("a", "A.m4b", 100)] + let controller = makeController() + await controller.submitIfNeeded() + requestPending = false + + await controller.submitIfNeeded() + + XCTAssertEqual(submitted.count, 2) + XCTAssertEqual(controller.state, .starting) + } + + /// An import (or the first sync after signing in) queues books: that alone submits it + func testQueuedBooks_submitTheTask() async throws { + pending = [book("a", "A.m4b", 100)] + let controller = makeController() + + NotificationCenter.default.post(name: .bookUploadsQueued, object: nil) + + try await waitUntil { !submitted.isEmpty } + XCTAssertEqual(submitted.count, 1) + XCTAssertEqual(controller.state, .starting) + } + + func testRunningTask_reportsProgressAndEndsWhenTheQueueEmpties() async throws { + pending = [book("a", "A.m4b", 100), book("b", "B.m4b", 100)] + let controller = makeController() + let task = FakeContinuedTask() + + controller.attach(task) + try await waitUntil { !task.titles.isEmpty } + XCTAssertEqual(controller.state, .running) + XCTAssertEqual(task.titles.last?.1, "(1 / 2) A.m4b") + XCTAssertEqual(task.progress.totalUnitCount, 200) + + NotificationCenter.default.post( + name: .uploadProgressUpdated, + object: nil, + userInfo: ["uuid": "a", "relativePath": "Folder/A.m4b", "progress": 0.5] + ) + try await waitUntil { task.progress.completedUnitCount == 50 } + XCTAssertEqual(task.progress.completedUnitCount, 50) + + pending = [book("b", "B.m4b", 100)] + changes.send(QueueCounts(byQueueKey: [TaskQueueKey.uploadFile: 1])) + try await waitUntil { task.titles.last?.1 == "(2 / 2) B.m4b" } + XCTAssertEqual(task.titles.last?.1, "(2 / 2) B.m4b") + + pending = [] + changes.send(QueueCounts()) + try await waitUntil { !task.completions.isEmpty } + XCTAssertEqual(task.completions, [true]) + XCTAssertEqual(controller.state, .idle) + } + + /// A book moving between lanes looks absent for a moment: that's not the end + func testRunningTask_momentarilyEmptyQueue_keepsRunning() async throws { + pending = [book("a", "A.m4b", 100)] + let controller = makeController() + let task = FakeContinuedTask() + controller.attach(task) + try await waitUntil { !task.titles.isEmpty } + + pending = [] + changes.send(QueueCounts()) + try await Task.sleep(for: .milliseconds(20)) + pending = [book("a", "A.m4b", 100)] + try await Task.sleep(for: .milliseconds(150)) + + XCTAssertTrue(task.completions.isEmpty) + XCTAssertEqual(controller.state, .running) + } + + func testRunningTask_withOnlyParkedUploadsLeft_endsUnsuccessful() async throws { + pending = [] + parkedCount = 1 + let controller = makeController() + let task = FakeContinuedTask() + + controller.attach(task) + + try await waitUntil { !task.completions.isEmpty } + XCTAssertEqual(task.completions, [false]) + } + + /// Books stuck behind a blocked lane (or an account-level pause) wait on the user: the + /// run ends instead of holding the Live Activity until iOS expires it + func testRunningTask_behindABlockedLane_ends() async throws { + pending = [PendingBookUpload(uuid: "a", relativePath: "A.m4b", fileSize: 1, queueKey: TaskQueueKey.sync)] + let controller = makeController() + let task = FakeContinuedTask() + controller.attach(task) + try await waitUntil { !task.titles.isEmpty } + + currentCounts = QueueCounts(byQueueKey: [TaskQueueKey.sync: 2], pausedByQueueKey: [TaskQueueKey.sync: 1], blockedQueueKeys: [TaskQueueKey.sync]) + changes.send(currentCounts) + + try await waitUntil { !task.completions.isEmpty } + XCTAssertEqual(task.completions, [false]) + } + + /// iOS launched it before the services were up (a cold launch into the task): it's held, + /// not failed, and runs once they are + func testTaskLaunchedBeforeSetup_isHeldUntilTheServicesExist() async throws { + pending = [book("a", "A.m4b", 100)] + let controller = UploadContinuationController() + let task = FakeContinuedTask() + + controller.attach(task) + XCTAssertTrue(task.completions.isEmpty) + XCTAssertNotNil(task.expirationHandler, "expiry is answered even while held") + + controller.setup(dependencies: dependencies()) + try await waitUntil { !task.titles.isEmpty } + XCTAssertEqual(controller.state, .running) + } + + /// Expired while held (the services never came up in time): not attached afterwards + func testTaskExpiredWhileHeld_isNotAttachedLater() async throws { + pending = [book("a", "A.m4b", 100)] + let controller = UploadContinuationController() + let task = FakeContinuedTask() + controller.attach(task) + + task.expirationHandler?() + try await Task.sleep(for: .milliseconds(50)) + controller.setup(dependencies: dependencies()) + try await Task.sleep(for: .milliseconds(100)) + + XCTAssertEqual(task.completions, [false]) + XCTAssertEqual(controller.state, .idle) + XCTAssertTrue(task.titles.isEmpty) + } + + /// Every waiting book sits behind a paused lane: no Live Activity that fails at once + func testSubmit_behindABlockedLane_isHeldBack() async throws { + pending = [PendingBookUpload(uuid: "a", relativePath: "A.m4b", fileSize: 1, queueKey: TaskQueueKey.sync)] + let controller = makeController() + currentCounts = QueueCounts(byQueueKey: [TaskQueueKey.sync: 2], pausedByQueueKey: [TaskQueueKey.sync: 1], blockedQueueKeys: [TaskQueueKey.sync]) + + await controller.submitIfNeeded() + + XCTAssertTrue(submitted.isEmpty) + XCTAssertEqual(controller.state, .idle) + } + + // MARK: - The "Continue in background" buttons + + private func uploadLaneCounts(waiting: Int, paused: Int = 0, blocked: Bool = false) -> QueueCounts { + QueueCounts( + byQueueKey: [TaskQueueKey.uploadFile: waiting + paused], + pausedByQueueKey: paused > 0 ? [TaskQueueKey.uploadFile: paused] : [:], + blockedQueueKeys: blocked ? [TaskQueueKey.uploadFile] : [] + ) + } + + func testOffer_showsOnlyWhenUploadsCanRunAndNothingIsLive() async throws { + let controller = makeController() + XCTAssertFalse(controller.isOfferAvailable, "nothing waiting") + + changes.send(uploadLaneCounts(waiting: 2)) + try await waitUntil { controller.isOfferAvailable } + XCTAssertTrue(controller.isOfferAvailable) + + networkAllows = false + XCTAssertFalse(controller.isOfferAvailable, "Wi-Fi only, on cellular") + networkAllows = true + canUpload = false + XCTAssertFalse(controller.isOfferAvailable, "no S3 access") + canUpload = true + + changes.send(uploadLaneCounts(waiting: 0, paused: 2)) + try await waitUntil { !controller.isOfferAvailable } + XCTAssertFalse(controller.isOfferAvailable, "only parked uploads") + + // An account-level pause blocks the upload lane itself + changes.send(uploadLaneCounts(waiting: 2, blocked: true)) + try await waitUntil { controller.counts.isBlocked(TaskQueueKey.uploadFile) } + XCTAssertFalse(controller.isOfferAvailable, "the lane is blocked") + } + + func testOffer_hidesWhileATaskRuns() async throws { + pending = [book("a", "A.m4b", 100)] + let controller = makeController() + changes.send(uploadLaneCounts(waiting: 1)) + try await waitUntil { controller.isOfferAvailable } + + controller.attach(FakeContinuedTask()) + + XCTAssertEqual(controller.state, .running) + XCTAssertFalse(controller.isOfferAvailable) + } + + func testButton_submitsTheTask_andHidesWhileStarting() async throws { + pending = [book("a", "A.m4b", 100)] + let controller = makeController() + changes.send(uploadLaneCounts(waiting: 1)) + try await waitUntil { controller.isOfferAvailable } + + controller.continueInBackground() + + try await waitUntil { !submitted.isEmpty } + XCTAssertEqual(controller.state, .starting) + XCTAssertFalse(controller.isOfferAvailable) + } + + /// A request iOS dropped leaves "Starting…": re-checked when the screen appears + func testRefreshState_clearsARequestIOSDropped() async { + pending = [book("a", "A.m4b", 100)] + let controller = makeController() + await controller.submitIfNeeded() + XCTAssertEqual(controller.state, .starting) + + await controller.refreshState() + XCTAssertEqual(controller.state, .starting, "still pending with iOS") + + requestPending = false + await controller.refreshState() + XCTAssertEqual(controller.state, .idle) + } + + /// Our own submit still on its way to iOS isn't a dropped request + func testRefreshState_duringAnInFlightSubmit_keepsStarting() async throws { + pending = [book("a", "A.m4b", 100)] + var release: CheckedContinuation? + var deps = dependencies() + deps.submit = { _ in await withCheckedContinuation { release = $0 } } + let controller = UploadContinuationController() + controller.setup(dependencies: deps) + let submitting = Task { await controller.submitIfNeeded() } + try await waitUntil { release != nil } + requestPending = false + + await controller.refreshState() + XCTAssertEqual(controller.state, .starting) + + release?.resume() + await submitting.value + XCTAssertEqual(controller.state, .starting) + } + + /// A Retry that just unblocked the lane counts at once, before the throttled counts catch up + func testSubmit_readsTheCurrentCounts_notTheThrottledOnes() async throws { + pending = [PendingBookUpload(uuid: "a", relativePath: "A.m4b", fileSize: 1, queueKey: TaskQueueKey.sync)] + let controller = makeController() + changes.send(QueueCounts(byQueueKey: [TaskQueueKey.sync: 2], pausedByQueueKey: [TaskQueueKey.sync: 1], blockedQueueKeys: [TaskQueueKey.sync])) + try await waitUntil { controller.counts.isBlocked(TaskQueueKey.sync) } + XCTAssertTrue(controller.counts.isBlocked(TaskQueueKey.sync), "the throttled counts still say blocked") + currentCounts = QueueCounts(byQueueKey: [TaskQueueKey.sync: 2]) + + await controller.submitIfNeeded() + + XCTAssertEqual(submitted.count, 1) + } + + func testRunningPercent_followsTheTask_andClearsWhenItEnds() async throws { + pending = [book("a", "A.m4b", 100)] + let controller = makeController() + let task = FakeContinuedTask() + controller.attach(task) + try await waitUntil { controller.runningPercent != nil } + XCTAssertEqual(controller.runningPercent, 0) + + NotificationCenter.default.post( + name: .uploadProgressUpdated, + object: nil, + userInfo: ["uuid": "a", "relativePath": "Folder/A.m4b", "progress": 0.37] + ) + try await waitUntil { controller.runningPercent == 37 } + XCTAssertEqual(controller.runningPercent, 37) + + task.expirationHandler?() + try await waitUntil { controller.state == .idle } + XCTAssertNil(controller.runningPercent) + } + + /// Expiry (and a cancel from the Live Activity) is answered once, right in the handler + func testExpiration_completesTheTaskOnce() async throws { + pending = [book("a", "A.m4b", 100)] + let controller = makeController() + let task = FakeContinuedTask() + controller.attach(task) + try await waitUntil { !task.titles.isEmpty } + + task.expirationHandler?() + XCTAssertEqual(task.completions, [false]) + try await waitUntil { controller.state == .idle } + + pending = [] + changes.send(QueueCounts()) + try await Task.sleep(for: .milliseconds(150)) + XCTAssertEqual(task.completions, [false], "not completed a second time") + } +} diff --git a/BookPlayerTests/Services/VoiceOverServiceTest.swift b/BookPlayerTests/Services/VoiceOverServiceTest.swift index cf7d63c2f..df892b171 100644 --- a/BookPlayerTests/Services/VoiceOverServiceTest.swift +++ b/BookPlayerTests/Services/VoiceOverServiceTest.swift @@ -34,4 +34,81 @@ class VoiceOverServiceTest: XCTestCase { XCTAssert(VoiceOverService.fastForwardText() == localizedString) } + + // MARK: - Media-server source + + private func makeItem(resources: [SimpleExternalResource]?) -> SimpleLibraryItem { + SimpleLibraryItem( + title: "CD 01- 001", + details: "Timothy Ferriss", + speed: 1, + currentTime: 0, + duration: 100, + percentCompleted: 0, + isFinished: false, + relativePath: "cd-01-001.mp3", + remoteURL: nil, + artworkURL: nil, + orderRank: 0, + parentFolder: nil, + originalFileName: "cd-01-001.mp3", + lastPlayDate: nil, + type: .book, + uuid: UUID().uuidString, + externalResources: resources + ) + } + + private func resource(_ provider: ExternalResource.ProviderName) -> SimpleExternalResource { + SimpleExternalResource( + providerName: provider.rawValue, + providerId: "1", + syncStatus: ExternalResource.SyncStatus.stream.rawValue, + lastSyncedAt: nil + ) + } + + /// The Watch cell draws no provider glyph, so its announcement must not grow one. + func testAccessibilityLabelOmitsSourceByDefault() { + let label = VoiceOverService.getAccessibilityLabel(for: makeItem(resources: [resource(.jellyfin)])) + + XCTAssertFalse(label.contains("Jellyfin"), label) + } + + /// `BookView` draws the glyph ahead of the author, so the name leads the announcement. + func testAccessibilityLabelLeadsWithMediaServerSource() { + let label = VoiceOverService.getAccessibilityLabel( + for: makeItem(resources: [resource(.jellyfin)]), + includeSource: true + ) + + XCTAssertTrue(label.hasPrefix("Jellyfin, "), label) + } + + /// `externalResources` comes off an unordered NSSet, so two providers must not swap + /// between launches. + func testAccessibilityLabelSourceOrderIsStable() { + let resources = [resource(.jellyfin), resource(.audiobookshelf)] + + for ordering in [resources, resources.reversed()] { + let label = VoiceOverService.getAccessibilityLabel( + for: makeItem(resources: Array(ordering)), + includeSource: true + ) + + XCTAssertTrue(label.hasPrefix("Audiobookshelf, Jellyfin, "), label) + } + } + + /// Hardcover is progress-sync, not a source the book streams from — it earns no glyph in + /// `BookView`, so it earns no mention here either. + func testAccessibilityLabelSkipsNonMediaServerSources() { + let label = VoiceOverService.getAccessibilityLabel( + for: makeItem(resources: [resource(.hardcover)]), + includeSource: true + ) + + XCTAssertFalse(label.contains("Hardcover"), label) + XCTAssertTrue(label.hasPrefix("CD 01- 001"), label) + } } diff --git a/BookPlayerTests/SharedWidgetStoreTests.swift b/BookPlayerTests/SharedWidgetStoreTests.swift index dc088fd89..191ff3ad7 100644 --- a/BookPlayerTests/SharedWidgetStoreTests.swift +++ b/BookPlayerTests/SharedWidgetStoreTests.swift @@ -34,6 +34,7 @@ class SharedWidgetStoreTests: XCTestCase { duration: 100, relativePath: relativePath, remoteURL: nil, + externalURL: nil, index: 0 ) return PlayableItem( diff --git a/BookPlayerTests/ViewModels/ChaptersViewModelTests.swift b/BookPlayerTests/ViewModels/ChaptersViewModelTests.swift index 3cb1789ab..2b4fed260 100644 --- a/BookPlayerTests/ViewModels/ChaptersViewModelTests.swift +++ b/BookPlayerTests/ViewModels/ChaptersViewModelTests.swift @@ -30,6 +30,7 @@ final class ChaptersViewModelTests: XCTestCase { duration: 10, relativePath: relativePath, remoteURL: nil, + externalURL: nil, index: Int16(index) ) } diff --git a/BookPlayerWatch/CoreServices.swift b/BookPlayerWatch/CoreServices.swift index 064b15df4..d0844df2b 100644 --- a/BookPlayerWatch/CoreServices.swift +++ b/BookPlayerWatch/CoreServices.swift @@ -13,6 +13,7 @@ class CoreServices: ObservableObject { let dataManager: DataManager let accountService: AccountServiceProtocol var syncService: SyncServiceProtocol + var syncQueueService: SyncQueueServiceProtocol let libraryService: LibraryService let playbackService: PlaybackServiceProtocol let playerManager: PlayerManager @@ -29,6 +30,7 @@ class CoreServices: ObservableObject { dataManager: DataManager, accountService: AccountServiceProtocol, syncService: SyncServiceProtocol, + syncQueueService: SyncQueueServiceProtocol, libraryService: LibraryService, playbackService: PlaybackServiceProtocol, playerManager: PlayerManager, @@ -39,6 +41,7 @@ class CoreServices: ObservableObject { self.dataManager = dataManager self.accountService = accountService self.syncService = syncService + self.syncQueueService = syncQueueService self.libraryService = libraryService self.playbackService = playbackService self.hasSyncEnabled = accountService.hasSyncEnabled() @@ -50,10 +53,13 @@ class CoreServices: ObservableObject { func checkAndReloadIfSyncIsEnabled() { self.hasSyncEnabled = accountService.hasSyncEnabled() + // SyncQueueService re-derives its own policy from .accountUpdate (posted by the + // Shared AccountService update this flow just ran) — no manual forwarding needed. } func updateSyncEnabled(_ enabled: Bool) { hasSyncEnabled = enabled syncService.updateSyncEnabled(enabled) } + } diff --git a/BookPlayerWatch/ExtensionDelegate.swift b/BookPlayerWatch/ExtensionDelegate.swift index 378a1fdef..8e79800f6 100644 --- a/BookPlayerWatch/ExtensionDelegate.swift +++ b/BookPlayerWatch/ExtensionDelegate.swift @@ -71,12 +71,24 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate, ObservableObject { let audioMetadataService = AudioMetadataService() let libraryService = LibraryService() libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) + let tasksDataManager = TasksDataManager() + let syncQueueService = SyncQueueService() + // No Queued Tasks screen here to show or retry a parked task: coded failures drop + syncQueueService.parkingEnabled = false + syncQueueService.setup( + libraryService: libraryService, + getAccessLevel: { accountService.getAccessLevel() }, + verifySyncEntitlement: { await accountService.refreshSyncEntitlement() }, + tasksDataManager: tasksDataManager, + networkClient: NetworkClient(), + dataManager: dataManager + ) let syncService = SyncService() syncService.setup( isActive: accountService.hasSyncEnabled(), libraryService: libraryService, accountService: accountService, - dataManager: dataManager + syncQueueService: syncQueueService ) let playbackService = PlaybackService() playbackService.setup(libraryService: libraryService) @@ -110,6 +122,7 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate, ObservableObject { dataManager: dataManager, accountService: accountService, syncService: syncService, + syncQueueService: syncQueueService, libraryService: libraryService, playbackService: playbackService, playerManager: playerManager, @@ -302,6 +315,13 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate, ObservableObject { extension ExtensionDelegate: PurchasesDelegate { func purchases(_ purchases: Purchases, receivedUpdated customerInfo: CustomerInfo) { - coreServices?.updateSyncEnabled(customerInfo.entitlements.all["pro"]?.isActive == true) + let enableSync = customerInfo.entitlements.all["pro"]?.isActive == true + || customerInfo.entitlements.all["lite"]?.isActive == true + coreServices?.updateSyncEnabled(enableSync) + + // Same route as iOS's MainCoordinator delegate: AccountService derives the level + // and posts .accountUpdate, which SyncQueueService (and SyncService) observe — + // this replaced a hand-rolled entitlement parse forwarded manually to the queue. + coreServices?.accountService.updateAccount(from: customerInfo) } } diff --git a/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift b/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift index e63a5c61c..8160be045 100644 --- a/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift +++ b/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift @@ -183,9 +183,13 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { fileURL = chapterURL } else { isFetchingRemoteURL = true + // For a bound book the chapter is a child file and `currentItem` is the parent: + // sending the parent's uuid would make the API return the parent row (no file + // URL) instead of this chapter, so resolve bound chapters by path only. + let itemUUID = currentItem?.isBoundBook == true ? nil : currentItem?.uuid fileURL = try await syncService - .getRemoteFileURLs(of: chapter.relativePath, for: currentItem?.uuid, type: .book)[0].url + .getRemoteFileURLs(of: chapter.relativePath, for: itemUUID, type: .book)[0].url isFetchingRemoteURL = false } diff --git a/BookPlayerWidgets/BookPlayerWidgets.swift b/BookPlayerWidgets/BookPlayerWidgets.swift index c56e22f7c..d64526ba2 100644 --- a/BookPlayerWidgets/BookPlayerWidgets.swift +++ b/BookPlayerWidgets/BookPlayerWidgets.swift @@ -19,11 +19,7 @@ import WidgetKit struct BookPlayerBundle { static func main() { #if os(iOS) - if #available(iOSApplicationExtension 18.0, *) { - IOSWidgetsBundle18.main() - } else { - IOSWidgetsBundle.main() - } + IOSWidgetsBundle.main() #elseif os(watchOS) WatchWidgetsBundle.main() #endif @@ -31,14 +27,6 @@ struct BookPlayerBundle { #if os(iOS) struct IOSWidgetsBundle: WidgetBundle { - var body: some Widget { - LastPlayedWidget() - TimeListenedWidget() - } - } - - @available(iOSApplicationExtension 18.0, *) - struct IOSWidgetsBundle18: WidgetBundle { var body: some Widget { LastPlayedWidget() TimeListenedWidget() diff --git a/BookPlayerWidgets/Phone/BookPlaybackToggleIntent.swift b/BookPlayerWidgets/Phone/BookPlaybackToggleIntent.swift index 260978f8d..9e89263c4 100644 --- a/BookPlayerWidgets/Phone/BookPlaybackToggleIntent.swift +++ b/BookPlayerWidgets/Phone/BookPlaybackToggleIntent.swift @@ -11,7 +11,7 @@ import AppIntents import BookPlayerKit import Foundation -@available(iOS 17.0, macOS 14.0, watchOS 10.0, *) +@available(macOS 14.0, watchOS 10.0, *) struct BookPlaybackToggleIntent: AudioPlaybackIntent { static var title: LocalizedStringResource = .init("Toggle playback of book") diff --git a/BookPlayerWidgets/Phone/LastPlayed/LastPlayedView.swift b/BookPlayerWidgets/Phone/LastPlayed/LastPlayedView.swift index 359419762..5707a4bfd 100644 --- a/BookPlayerWidgets/Phone/LastPlayed/LastPlayedView.swift +++ b/BookPlayerWidgets/Phone/LastPlayed/LastPlayedView.swift @@ -50,27 +50,23 @@ struct LastPlayedView: View { return VStack(alignment: .leading) { if let relativePath = model.relativePath { HStack { - if #available(iOSApplicationExtension 17.0, iOS 17.0, *) { - Button(intent: BookPlaybackToggleIntent(relativePath: relativePath)) { - ZStack { - getArtworkView(for: relativePath) - Circle() - .foregroundStyle(.white) - .frame(width: 30, height: 30) - .opacity(0.8) - Image(systemName: imageName) - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundStyle(.black) - .frame(width: 11, height: 11) - .offset(x: model.isPlaying ? 0 : 1) - .widgetAccentable() - } + Button(intent: BookPlaybackToggleIntent(relativePath: relativePath)) { + ZStack { + getArtworkView(for: relativePath) + Circle() + .foregroundStyle(.white) + .frame(width: 30, height: 30) + .opacity(0.8) + Image(systemName: imageName) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(.black) + .frame(width: 11, height: 11) + .offset(x: model.isPlaying ? 0 : 1) + .widgetAccentable() } - .buttonStyle(.plain) - } else { - getArtworkView(for: relativePath) } + .buttonStyle(.plain) VStack { Image(appIconName) diff --git a/BookPlayerWidgets/Phone/LastPlayed/LastPlayedWidgetView.swift b/BookPlayerWidgets/Phone/LastPlayed/LastPlayedWidgetView.swift index 3f7a0ca0f..b2ccc7b3a 100644 --- a/BookPlayerWidgets/Phone/LastPlayed/LastPlayedWidgetView.swift +++ b/BookPlayerWidgets/Phone/LastPlayed/LastPlayedWidgetView.swift @@ -45,6 +45,6 @@ struct LastPlayedWidget: Widget { .configurationDisplayName("Last Played Books") .description("See and play your last played books") .supportedFamilies([.systemSmall, .systemMedium]) - .contentMarginsDisabledIfAvailable() + .contentMarginsDisabled() } } diff --git a/BookPlayerWidgets/Phone/PlayLastControlWidgetView.swift b/BookPlayerWidgets/Phone/PlayLastControlWidgetView.swift index 010dceb7b..45ed6ef4b 100644 --- a/BookPlayerWidgets/Phone/PlayLastControlWidgetView.swift +++ b/BookPlayerWidgets/Phone/PlayLastControlWidgetView.swift @@ -11,7 +11,6 @@ import Foundation import SwiftUI import WidgetKit -@available(iOSApplicationExtension 18.0, iOS 18.0, *) struct PlayLastControlWidgetView: ControlWidget { var body: some ControlWidgetConfiguration { StaticControlConfiguration( diff --git a/BookPlayerWidgets/Phone/RecentBooks/RecentBooksWidgetView.swift b/BookPlayerWidgets/Phone/RecentBooks/RecentBooksWidgetView.swift index 7e55f588d..17ec87794 100644 --- a/BookPlayerWidgets/Phone/RecentBooks/RecentBooksWidgetView.swift +++ b/BookPlayerWidgets/Phone/RecentBooks/RecentBooksWidgetView.swift @@ -103,18 +103,7 @@ struct RecentBooksWidgetView: View { .widgetAccentable() HStack { ForEach(items, id: \.relativePath) { item in - if #available(iOSApplicationExtension 17.0, iOS 17.0, *) { - Button(intent: BookPlaybackToggleIntent(relativePath: item.relativePath)) { - BookView( - item: item, - titleColor: widgetColors.primaryColor, - theme: entry.theme, - isPlaying: item.relativePath == entry.currentlyPlaying - ) - .frame(minWidth: 0, maxWidth: .infinity) - } - .buttonStyle(.plain) - } else { + Button(intent: BookPlaybackToggleIntent(relativePath: item.relativePath)) { BookView( item: item, titleColor: widgetColors.primaryColor, @@ -123,6 +112,7 @@ struct RecentBooksWidgetView: View { ) .frame(minWidth: 0, maxWidth: .infinity) } + .buttonStyle(.plain) } } .padding([.leading, .trailing]) @@ -148,7 +138,7 @@ struct RecentBooksWidget: Widget { .configurationDisplayName("Recent Books") .description("See the recent played books") .supportedFamilies([.systemMedium]) - .contentMarginsDisabledIfAvailable() + .contentMarginsDisabled() } } diff --git a/BookPlayerWidgets/Phone/TimeListened/TimeListenedWidgetView.swift b/BookPlayerWidgets/Phone/TimeListened/TimeListenedWidgetView.swift index c997102e8..a4a009188 100644 --- a/BookPlayerWidgets/Phone/TimeListened/TimeListenedWidgetView.swift +++ b/BookPlayerWidgets/Phone/TimeListened/TimeListenedWidgetView.swift @@ -184,6 +184,6 @@ struct TimeListenedWidget: Widget { .configurationDisplayName("Time Listened") .description("See how much time you have spent listening to audiobooks in the last few days") .supportedFamilies([.systemSmall, .systemMedium]) - .contentMarginsDisabledIfAvailable() + .contentMarginsDisabled() } } diff --git a/BookPlayerWidgets/Phone/WidgetUtils.swift b/BookPlayerWidgets/Phone/WidgetUtils.swift index 40c832a7f..fed6b3e9e 100644 --- a/BookPlayerWidgets/Phone/WidgetUtils.swift +++ b/BookPlayerWidgets/Phone/WidgetUtils.swift @@ -215,22 +215,8 @@ class WidgetUtils { extension View { public func widgetBackground(backgroundView: some View) -> some View { - if #available(watchOS 10.0, iOSApplicationExtension 17.0, iOS 17.0, macOSApplicationExtension 14.0, *) { - return containerBackground(for: .widget) { - backgroundView - } - } else { - return background(backgroundView) - } - } -} - -extension WidgetConfiguration { - public func contentMarginsDisabledIfAvailable() -> some WidgetConfiguration { - if #available(iOSApplicationExtension 17.0, *) { - return self.contentMarginsDisabled() - } else { - return self + return containerBackground(for: .widget) { + backgroundView } } } diff --git a/BookPlayerWidgets/Shared/SharedIconWidget/SharedIconWidget.swift b/BookPlayerWidgets/Shared/SharedIconWidget/SharedIconWidget.swift index 89569e767..8a4976c6d 100644 --- a/BookPlayerWidgets/Shared/SharedIconWidget/SharedIconWidget.swift +++ b/BookPlayerWidgets/Shared/SharedIconWidget/SharedIconWidget.swift @@ -36,7 +36,6 @@ struct SharedIconWidgetTimelineProvider: TimelineProvider { typealias Entry = SharedIconWidgetEntry } -@available(iOSApplicationExtension 16.1, watchOS 9.0, *) struct SharedIconWidget: Widget { let kind: String = Constants.Widgets.sharedIconWidget.rawValue #if os(watchOS) diff --git a/BookPlayerWidgets/Shared/SharedNowPlayingWidget/CircularView.swift b/BookPlayerWidgets/Shared/SharedNowPlayingWidget/CircularView.swift index 669e1635a..b7a2848fb 100644 --- a/BookPlayerWidgets/Shared/SharedNowPlayingWidget/CircularView.swift +++ b/BookPlayerWidgets/Shared/SharedNowPlayingWidget/CircularView.swift @@ -14,7 +14,6 @@ import BookPlayerWatchKit import BookPlayerKit #endif -@available(iOSApplicationExtension 16.1, *) struct CircularView: View { let title: String let fillFraction: Double diff --git a/BookPlayerWidgets/Shared/SharedNowPlayingWidget/CornerView.swift b/BookPlayerWidgets/Shared/SharedNowPlayingWidget/CornerView.swift index 36bf579f0..b5af2c835 100644 --- a/BookPlayerWidgets/Shared/SharedNowPlayingWidget/CornerView.swift +++ b/BookPlayerWidgets/Shared/SharedNowPlayingWidget/CornerView.swift @@ -9,7 +9,6 @@ import SwiftUI import WidgetKit -@available(iOSApplicationExtension 16.1, *) struct CornerView: View { let title: String let fillFraction: Double @@ -22,12 +21,8 @@ struct CornerView: View { .widgetAccentable() } - if #available(iOSApplicationExtension 17.0, watchOS 10.0, *) { - return view - .widgetCurvesContent() - } else { - return view - } + return view + .widgetCurvesContent() } } diff --git a/BookPlayerWidgets/Shared/SharedNowPlayingWidget/RectangularView.swift b/BookPlayerWidgets/Shared/SharedNowPlayingWidget/RectangularView.swift index 5035cb4c4..358be3b59 100644 --- a/BookPlayerWidgets/Shared/SharedNowPlayingWidget/RectangularView.swift +++ b/BookPlayerWidgets/Shared/SharedNowPlayingWidget/RectangularView.swift @@ -9,7 +9,6 @@ import SwiftUI import WidgetKit -@available(iOSApplicationExtension 16.1, *) struct RectangularView: View { let chapterTitle: String let bookTitle: String diff --git a/BookPlayerWidgets/Shared/SharedNowPlayingWidget/SharedWidget.swift b/BookPlayerWidgets/Shared/SharedNowPlayingWidget/SharedWidget.swift index 6718993f0..88a2307ac 100644 --- a/BookPlayerWidgets/Shared/SharedNowPlayingWidget/SharedWidget.swift +++ b/BookPlayerWidgets/Shared/SharedNowPlayingWidget/SharedWidget.swift @@ -150,7 +150,6 @@ struct SharedWidgetTimelineProvider: TimelineProvider { } } -@available(iOSApplicationExtension 16.1, *) struct SharedWidget: Widget { let kind: String = Constants.Widgets.sharedNowPlayingWidget.rawValue diff --git a/BookPlayerWidgets/Shared/SharedNowPlayingWidget/SharedWidgetContainerView.swift b/BookPlayerWidgets/Shared/SharedNowPlayingWidget/SharedWidgetContainerView.swift index aaacfbc55..ba60d5e56 100644 --- a/BookPlayerWidgets/Shared/SharedNowPlayingWidget/SharedWidgetContainerView.swift +++ b/BookPlayerWidgets/Shared/SharedNowPlayingWidget/SharedWidgetContainerView.swift @@ -8,7 +8,6 @@ import SwiftUI -@available(iOSApplicationExtension 16.1, *) struct SharedWidgetContainerView: View { let entry: SharedWidgetEntry diff --git a/CLAUDE.md b/CLAUDE.md index b5d59a5b2..81c40e177 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,10 @@ Nine native targets in `BookPlayer.xcodeproj` (no `Package.swift`, no app `.xcwo | `BookPlayerShareExtension` | app-extension | Share-sheet import | `BookPlayerShareExtension/` | | `BookPlayerTests` | "Audiobook PlayerTests" · unit-test | Unit + perf tests | `BookPlayerTests/` | +**Deployment targets:** iOS **26.0** for every iOS target, watchOS **10.0** for the watch app, `BookPlayerWatchKit` +and the watch widgets. An `#available(iOS 18…26)` check is dead code on iOS, but `Shared/` and the shared widget +files also compile for watchOS 10, so an iOS-only API there still needs its `#if os(iOS)` / availability guard. + **Dependency rule:** app → `BookPlayerKit` (`import BookPlayerKit`). `Shared/` must **not** import app-layer types — that breaks the framework boundary and is a 🔴 finding. Watch/shared code selects the framework with: @@ -65,8 +69,8 @@ App Intents in the top-level `BookPlayer/BookPlayer/AppIntents/` folder — don' ### Dependencies (SPM, declared inside `project.pbxproj`) -RevenueCat (`purchases-ios`, ~5.33), Sentry (`sentry-cocoa`, **exact 8.36.0**), Kingfisher (~7.9), -JellyfinAPI (`jellyfin-sdk-swift`, ~0.4), MarqueeLabel (~4.0.5), DeviceKit (~5.1), +RevenueCat (`purchases-ios`, ~5.78), Sentry (`sentry-cocoa`, **exact 8.36.0**), Kingfisher (~8.10), +JellyfinAPI (`jellyfin-sdk-swift`, ~0.4), Get (~2.2), MarqueeLabel (~4.0.5), DeviceKit (~5.1), IDZSwiftCommonCrypto (~0.13.1), Themeable (~3.0), ZipArchive (~2.3), DirectoryWatcher (~2.8.6). `BlurHashDecode.swift` is vendored (SwiftLint-excluded). RevenueCat + Kingfisher link into the **frameworks**; most others link into the **app**. SwiftLint runs as a build-phase run-script; **Sourcery is run manually** (not @@ -76,10 +80,13 @@ a build phase) and its output is committed. ## Build, CI & tooling (what the tools own — don't hand-police it) -- **CI** (`.github/workflows/ci.yml`): runner `macos-26`, **Xcode 26.4**, `build-for-testing` then +- **CI** (`.github/workflows/ci.yml`): runner `xcode-27` (GitHub's preview image), **Xcode 27.0** + (`/Applications/Xcode_27.0.app`, set with `xcode-select`). Xcode 27 is required: the upload code calls iOS 27 + SDK APIs behind `#available` (`BGTaskScheduler.submitTaskRequest`), which Xcode 26 can't compile. It copies + `Debug.template.xcconfig` → `Debug.xcconfig`, resolves SPM, resets the simulators, then `build-for-testing` and `test-without-building` with the **`Unit Tests`** test plan, `-only-testing:BookPlayerTests`, simulator - `iPhone 17`. It first copies `Debug.template.xcconfig` → `Debug.xcconfig`. Triggers on push to `main`/`develop` - and PRs to `develop`. + `iPhone 17`. Triggers on push to `main`/`develop` and PRs to `develop`. Release builds come from Xcode Cloud + (`ci_scripts/`), whose Xcode version is set in App Store Connect and must be 27 too. - **Lint/format is enforced by tools — do NOT flag style they own.** `.swiftlint.yml` **disables**: `line_length`, `identifier_name`, `type_name`, `type_body_length`, `file_length`, `nesting`, `force_try`, `trailing_comma`, `trailing_newline`, `trailing_whitespace`, `switch_case_alignment`, `private_over_fileprivate`, `opening_brace`, @@ -109,7 +116,7 @@ a build phase) and its output is committed. `MainCoordinator`, retains it, and calls `start()`. 5. `MainCoordinator.start()` hosts SwiftUI `MainView` inside `AppHostingViewController` (a `UIHostingController` subclass that only overrides orientation) and **modally presents it full-screen** over the loading nav - controller. `MainView` is a `TabView` (Library / Profile / Settings [+ iOS 26 Search]) with a mini-player + controller. `MainView` is a `TabView` (Library / Profile / Settings [+ Search on iPhone]) with a mini-player overlay and a `.fullScreenCover` for the player. **Invariant:** the strong chain `SceneDelegate → LoadingCoordinator.mainCoordinator → MainCoordinator` is the @@ -121,7 +128,10 @@ first. Reordering boot risks a launch crash. - `@MainActor final class AppServices` with `static let shared` + `private init()`. Owns the async `setupCoreServicesTask`, the `DatabaseInitializer`, and a shared `PlayerState`. -- `CoreServices` (`BookPlayer/Utils/CoreServices.swift`) is a struct of exactly **10 services**: `accountService`, +- `CoreServices` (`BookPlayer/Utils/CoreServices.swift`) is a struct of exactly **12 services**: `accountService`, + `syncQueueService`, `externalProgressService` (pulls media-server playback positions for lite/pro accounts; + self-subscribes to `.bookPlayed` for the on-play prompt, and `ListSyncRefreshService.syncList` drives the + per-level list pull through the one-method `ExternalProgressRefreshing` seam), `dataManager`, `hardcoverService`, `libraryService`, `playbackService`, `playerLoaderService`, `playerManager`, `preferencesService` (`PreferencesSyncService`), `syncService`, `watchService` (`PhoneWatchConnectivityService`). - **Two-step `init()` + `setup(...)` DI pattern:** services are created empty then configured, e.g. @@ -134,11 +144,32 @@ first. Reordering boot risks a launch crash. - **Services → coordinators:** `CoreServices` is passed whole into `MainCoordinator.init(...)`, which also builds coordinator-scoped services (`ImportManager`, `ListSyncRefreshService`, `SingleFileDownloadService`, `JellyfinConnectionService`, `AudiobookShelfConnectionService`). + `ListSyncRefreshService.syncList(at:)` is the ONE list-refresh entry point (list appear, pull-to-refresh, sync + activation, CarPlay): cloud contents for the level → the level's media-server progress pull → preferences pull. + A folder's cloud step is skipped until the first sync has run (`SyncService.hasRunFirstSync`, backed by + `hasScheduledLibraryContents`): its listing would delete local items the server hasn't seen yet. The root refresh then asks, without waiting, for + `SyncService.scheduleMissingItemsIfNeeded()` (the weekly / became-PRO missing-items pass). + The pull runs strictly AFTER the cloud step and never alongside it (cloud writes on the background context, the + progress ingest on the view context; no merge policy), whatever the cloud outcome. It is resource-first: + `LibraryService.findMediaServerResources(at:)` fetches only the level's media-server `ExternalResource` rows + (direct children, providers filtered in SQL from `ProviderName.mediaServerRawValues`) on the background context, + so the main thread does nothing but the ingest. `ExternalResource.ProviderName.mediaServer` — which maps a + provider onto the `ExternalResource.MediaServerProvider` sub-enum (`jellyfin`, `audiobookshelf`) — is the single + exhaustive "is this a media server" switch; `isMediaServer`, `SimpleExternalResource.mediaServer`, the SQL filter, + and every server-only switch (stream source, host display, progress push, the library-row glyph) derive from it. - **Services → SwiftUI:** `ObservableObject`s (`playerManager`, `importManager`, `singleFileDownloadService`, - `listSyncRefreshService`) via `.environmentObject`; the rest via `.environment(\.key, …)`. The environment keys + `listSyncRefreshService`) via `.environmentObject`, plus `externalImportEvents`, which `MainView` owns as a + `@StateObject` and injects itself — the coordinator builds services, not SwiftUI-internal wires; the rest via `.environment(\.key, …)`. + `ExternalImportEvents` is a stateless wire (integrations send confirmed virtual-import batches; `LibraryRootView` + consumes and inserts) that conforms to `ObservableObject` solely for the loud-injection contract — `ImportManager` + itself carries no external-import state or publishers. The environment keys live in `BookPlayer/Utils/Extensions/Environment+BookPlayer.swift` (`@Entry`). **Each `@Entry` default is a throwaway placeholder (an un-`setup()` service).** A view that reads the environment default instead of the - injected instance silently gets a non-functional service — verify real injection by `MainCoordinator`. + injected instance gets a non-functional service: stored-property reads return inert defaults, but METHODS that + touch un-`setup()` dependencies trap on their implicitly-unwrapped optionals + (`SyncQueueService.observeQueueCounts` and siblings all behave this way — it is the pattern, not a + defect). Verify real injection by `MainCoordinator`; previews that exercise such views must construct and + inject set-up services (see `ProfileSyncTasksSectionView`'s preview), never rely on the defaults. - **App Intents DI:** only `playerLoaderService` and `libraryService` are registered via `AppDependencyManager.shared.add(...)` in `setupCoreServices()`. The **extension** targets consume them with `@Dependency` (under `#if !MAIN_APP`); the **main app** instead resolves via @@ -173,7 +204,7 @@ Custom `Notification.Name`s (namespaced with the bundle id at runtime) are the a CarPlay event bus. Declared in `Shared/Extensions/Notification+BookPlayerKit.swift` (framework-wide): `.chapterChange`, `.bookReady`, `.bookPlayed`, `.bookPaused`, `.bookEnd`, `.bookPlaying`, `.accountUpdate`, `.logout`, `.messageReceived`, `.folderProgressUpdated`, `.uploadProgressUpdated`, `.uploadCompleted`, -`.listeningProgressChanged`; and app-internal ones in `BookPlayer/Utils/Extensions/Notification+BookPlayer.swift`. +`.listeningProgressChanged`, `.syncTaskPaused`, `.backgroundSessionFinishedEvents`, `.bookUploadsQueued`; and app-internal ones in `BookPlayer/Utils/Extensions/Notification+BookPlayer.swift`. - **`PlayerManager` is the dominant publisher** of playback events; `PhoneWatchConnectivityService` and `CarPlayManager` are the dominant cross-target subscribers; `AccountService` is the auth/account hub. @@ -207,8 +238,9 @@ CarPlay event bus. Declared in `Shared/Extensions/Notification+BookPlayerKit.swi concrete subclasses `Book`/`Folder` must be used), plus `Library`, `Bookmark`, `Chapter`, `Account`, `Theme`, `PlaybackRecord`, `HardcoverBook`. `ItemType: Int16 { folder, bound, book }`. - **Migration is manual and staged** (`Shared/CoreData/Migrations/DataMigrationManager.swift` + `DBVersion.swift`, - currently `v1…v11`, current model `Audiobook Player 11`). It migrates **one version at a time** using explicit - `.xcmappingmodel`s where present (`v1→v2 … v3→v4`, `v7→v8 … v10→v11`; the v4–v7 hops rely on inference). A model + currently `v1…v12`, current model `Audiobook Player 12` — v12 adds the `ExternalResource` entity + the + `LibraryItem.externalResources` relationship for media-server items). It migrates **one version at a time** using explicit + `.xcmappingmodel`s where present (`v1→v2 … v3→v4`, `v7→v8 … v11→v12`; the v4–v7 hops rely on inference). A model change requires: **(1)** new `.xcdatamodel` version + bump `.xccurrentversion`; **(2)** new `DBVersion` case + `model()`; **(3)** a mapping model registered in `mappingModelName()` (inference is OFF, so anything non-trivial fails without one); **(4)** any custom data population added to the post-migration step; **(5)** @@ -219,16 +251,65 @@ CarPlay event bus. Declared in `Shared/Extensions/Notification+BookPlayerKit.swi - `TasksDataManager.swift` owns the `ModelContainer`. **Store is `applicationSupportDirectory/bp-synctasks.sqlite` — the app-support dir, NOT the App Group**, separate from the CoreData store. CloudKit disabled. - `container = try! ModelContainer(...)` — **`try!` crashes on any container/migration failure.** -- Versioned schema: `SchemaV1` (10 models) → `SchemaV2` (11 models, adds `MatchUuidsTaskModel` + a `uuid` field). - App code always uses the V2 typealiases. `@Model` types: `SyncTasksContainer`, `SyncTaskReferenceModel` - (`@Attribute(.unique) id`), and per-job payload models. + Before the container is built, `storeIsUnknownToMigrationPlan(at:)` checks the store's own metadata against + every `MigrationPlan.schemas` model; a store none of them can open (dev builds between schema edits, an older + build over a newer store) is MOVED aside (`.incompatible` suffix, never deleted) and a fresh one is created. + Don't match Cocoa codes (134504/134100) on the thrown error instead — SwiftData wraps them opaquely, so that + guard never fired. Any load failure that remains — a custom-migration-stage bug, a corrupt file, a fresh store + that won't open — still crashes (`fatalError`). +- Versioned schema: `SchemaV1` (10 models) → `SchemaV2` (11 models, adds `MatchUuidsTaskModel` + a `uuid` field) + → `SchemaV3` (unified concurrent-task container: `QueuedTaskReferenceModel` with per-queue keys, plus + `ExternalUpdateTaskModel`/`UploadFileTaskModel` payloads). App code always uses the V3 typealiases. - `MigrationPlan.swift` (`SchemaMigrationPlan`) has a **custom `v1ToV2` stage that reads UUIDs out of the CoreData `LibraryItem` table** — it requires `MigrationPlan.injectedCoreDataContext` to be set first, else - **`fatalError`**. This is the one coupling between the two stores; set it before the `ModelContainer` is built. + **`fatalError`** (the `v2ToV3` stage also uses the injected context, but degrades gracefully when absent). + This is the coupling between the two stores; set it before the `ModelContainer` is built. - **`ModelContext` is per-actor and not `Sendable`.** All task-queue reads/writes go through - `public actor SyncTasksStorage: ModelActor` (`Shared/Services/Sync/SyncTasksStorage.swift`) with a single - confined `ModelContext`. Do not share/pass a `ModelContext` across actors or threads. + `public actor SyncQueueRepository: ModelActor` (`Shared/Services/SyncQueue/`) with a single + confined `ModelContext` (it replaced the old `SyncTasksStorage` actor). Do not share/pass a `ModelContext` + across actors or threads. Execution lives in `SyncQueueService` (OperationQueue): the `sync` queue key runs + BookPlayer-server jobs serially; provider-named keys (externalUpdate pushes) and `uploadFile` run concurrently. + **The engine is the single owner of queue counts:** `observeQueueCounts()` publishes one per-lane `QueueCounts` + snapshot (the Profile row shows its `total`; the single Queued Tasks screen — one `DisclosureGroup` per lane, + sync first — reads `count(in:)` for its headers and lists every lane through `getOrderedQueuedJobs`). + `SyncService.canSyncListContents` gates list refresh on the `sync` lane only (repository `getTasksCount(in:)`), + and `AppDelegate.handleAppRefresh` waits on `laneDrained(TaskQueueKey.sync)` — S3 uploads and provider pushes + never block a refresh or hold a background window open (pushes retry forever against an unreachable server). + **Server-lane gate:** the BookPlayer-server lanes (`sync`, `uploadFile`) run only while + `SyncQueueService.serverLanesEnabled` is on. It starts off (workers wake in `setup`, before `SyncService` is + set up) and mirrors `SyncService.isActive` — `SyncService` is the ONLY writer: `setup` in the same synchronous + call as `isActive`, `updateSyncEnabled` and `logout` inside the main-actor block that writes `isActive` (so a + fast re-login can't be overwritten by a late logout write). Workers check it before every pop, so a lapse stops + a running lane after its current task. Gated tasks are **held, never cleared**: RevenueCat's cached info is nil + before the first fetch, so a paying subscriber can read inactive at launch. Provider lanes ignore the gate, and + `handleAppRefresh` completes immediately while it's off (a gated lane never drains). + **Parking:** a failed task parks ONLY on a coded failure — `SyncFailurePolicy.codedFailure` returns a + `CodedFailure` for the server's coded 4xx (`BookPlayerError.networkErrorWithCode`, its `error` key) and for the + app's own `UploadFileError.fileTooLarge` (`file_too_large`, no HTTP status); anything uncoded (network, 5xx, + the engine's retry-later) keeps the 5 s retry. Errors from both `LibraryItemSyncOperation` and + `FileUploadOperation` reach it. `SyncFailurePolicy` owns the rules: leaf tasks (`update`, + `uploadArtwork`, `uploadFile`) park alone (`TaskPauseScope.task`, the lane keeps running); every other + sync-lane task is structural and stops its lane (`.lane`); `not_subscribed`/`tier_required` park `.account` + (holds every server lane) and trigger a fresh RevenueCat read — inactive runs the normal lapse path, active or + failed keeps the tasks held and reports; `externalUpdate` keeps its own handling. The watch sets + `parkingEnabled = false` (no UI there), so task-level coded failures drop. The pause lives on + `QueuedTaskReferenceModel` (`pauseScope`, code, message, status, `pausedAt`, `sentryEventId`); + `SyncQueueRepository.getNextTask` skips `.task` rows and stops at a `.lane`/`.account` head, and + `TasksDataManager.queueCounts` mirrors those rules (paused counts, blocked lanes; `laneDrained` counts a lane + with nothing runnable as drained). Parked tasks and the running task (tracked by id in the repository — a Retry + can put a resumed task ahead of it) are never coalescing targets. Retry = one automatic resume at + launch (`setup`) plus `retryPausedTask(id:)`; there is deliberately no Skip. A first park posts + `.syncTaskPaused`, skipped when the row already carries a `sentryEventId` (recorded via `recordPauseReport`; + it survives resumes) and for a too-large book (decided: an expected limit, nothing to act on). The app-target `SyncPauseReporter` (owned by `AppServices`) turns it into ONE Sentry + warning per task, fingerprint `["sync-paused", jobType, errorCode]`, with a minimal payload (job type, code, + status, lane, scope, item uuid) — **never the server message or a path: both embed file names**. The Queued + Tasks row of a parked task shows the server's message with Retry and Report; Report mails (or, without Mail, + shares) `SyncPauseReport`: the paused task, every queued task with its state, and the local library tree with + uuids. A too-large book (`file_too_large`) instead shows the app's own message and a **Dismiss** action — the + only dismissible pause (`dismissPausedTask(id:)` refuses anything else; server-refused tasks never get a Skip). + A blocked lane's header turns red, Profile's "Queued sync tasks" title turns red with a warning icon while + anything is parked (its caption keeps showing last sync / progress), and a blocked pull-to-refresh says sync + is paused. - **Realm is gone** (Realm → SwiftData migration is complete). Only inert remnants remain (`DataManager.getSyncTasksRealmURL()` is dead; a stale comment in `LibraryService`). Don't reintroduce it. @@ -282,19 +363,114 @@ lines). It is the highest-risk file in the app. to `BookPlayerError` (`4xx` decode `ErrorResponse` → `.networkError`/`.networkErrorWithCode`, `5xx` → `.networkError`). Decoder is `.iso8601`. - **The bearer token must never be attached to S3/presigned or third-party (Jellyfin/ABS/Hardcover) URLs.** - Uploads to S3 presigned PUTs use `useKeychain: false` (the URL carries its own auth); media-server calls use + S3 presigned PUTs through `NetworkClient` (folders and bound books in `handleUploadJob`, artwork) use + `useKeychain: false` (the URL carries its own auth); book files never go through `NetworkClient` — their parts + are header-less background upload tasks (below). Media-server calls use their own connection tokens. Any new `request(url:...)` must set `useKeychain` deliberately — the default `true` attaches the JWT to whatever host is passed. - **Background `URLSession`s** (`Shared/Network/BPURLSession.swift`): two sessions (`.background` and `.background.cellular`) chosen by the `allowCellularData` default; downloads via `BPDownloadURLSession`. + They carry multipart upload PARTS, one upload task each, described `#@` + (`BackgroundPartUploadTransport`); `progressPublisher` emits `(task, bytesSent)`. + **Background wakes:** `AppDelegate.application(_:handleEventsForBackgroundURLSession:)` hands iOS's completion + handler to `BackgroundSessionWakeCoordinator` (names in `BackgroundTransferSessions`). For the upload sessions it + recreates them and answers only after `SyncQueueService.settleUploads()` — the running upload has handled the + delivered parts and queued the next ones (`FileUploadOperation.settle()`) — under `beginBackgroundTask`, with a + 25 s cap; calling the handler earlier suspends the app mid-request. The downloads session waits the same way + for `SyncService.settleDownloads()` (each finished download's follow-up: chapters, verification, scheduling — + Core Data in the App Group container); any other session (single-file media-server downloads) is answered right + away. Both delegates post `.backgroundSessionFinishedEvents` from SERIAL delegate queues (so it follows the last + completion); either order of handler and events works. The coordinator is created in `didFinishLaunching`, before + anything can wake a session. These session names are NOT `BGTaskSchedulerPermittedIdentifiers`. + **Continued task:** `UploadContinuationController` (app target, owned by `AppServices`, registered at launch as + `.uploads.continued`) runs ONE `BGContinuedProcessingTask` for the whole upload queue, so uploads keep + full speed in the background (Live Activity "Uploading files", "(x / n) ", progress in bytes). It is + submitted whenever books are queued for upload — `.bookUploadsQueued` (posted by `SyncService.handleItemsToUpload` + for an import, and by the missing-items pass for the first sync and on becoming PRO, never its weekly run; + decided: always, no size threshold), on a parked + task's Retry, and from the "Continue in background" buttons (the Queued Tasks File Uploads header, a + borderless button under Profile's Queued Tasks link; shown only when `isOfferAvailable`) — only from + the foreground, with S3 access, when the Wi-Fi-only setting allows the current network, and when some waiting + book sits in a lane that can run (not all behind a blocked lane). Its books come from + `SyncQueueService.pendingBookUploads()` (one repository read of both lanes: the sync lane's book `.upload`s — + not media-server ones, which carry no file — and pipe jobs, then the upload lane; parked ones counted apart); + refreshes are coalesced (one in flight). It ends when the queue stays empty for a moment (a book between + lanes can look absent; the upload task is stored BEFORE the sync `.upload` is popped), unsuccessfully if only + parked uploads remain or nothing can move (no S3 access, every waiting book behind a blocked lane). On expiry + or a Live Activity cancel it answers `setTaskCompleted(false)` in the handler and the parts carry on in the + background sessions. A task iOS launches before the services exist is held, not failed; a request iOS dropped + (`pendingTaskRequests`) no longer blocks the next submit. While a task runs, Profile's caption reads "Uploading in background · N%" + (`runningPercent`); Profile stays two lines (no Wi-Fi caption there — the banner is Queued Tasks only). +- **Book uploads are S3 multipart** (`FileUploadOperation`, the `uploadFile` lane; contract in + bookplayer-api `docs/multipart-uploads.md`). A non-nil `url` from `PUT /v1/library` only means "the server + needs the bytes" — the client never PUTs to it. **The server sets `synced` at `/upload/complete`; the client + never confirms a book upload it made** (folders and bound books are confirmed after their presigned PUT). A nil `url` is confirmed with `synced:true` for folders and bound books on + every tier, but for a book only when the tier has S3 access (`LibraryItemSyncOperation.canUploadFiles`, from + `accessPolicy[.uploadFile]` when `createOperation` builds it): there it means the file is already in S3, while on + LITE it only means the tier stores no file, so LITE books stay `synced=false` and the missing-items pass (below) + queues their files on becoming PRO. S3 is the source of truth: every run rebuilds from `GET /upload/parts` plus + the session's tasks for the current `uploadId`, so a relaunch or a lost event resumes without re-sending. The + resumable state (`uploadId`, `partSize`, `fileSize`, `restartCount`) lives on `UploadFileTaskModel`; parts are + sliced to `tmp/uploads//`; 64 MiB parts, 8 in flight (fewer on low disk), fresh part URLs every top-up + (403 = expired), up to 3 restarts (`upload_not_found`/`invalid_parts`/NoSuchUpload, or `complete` answering + `parts_missing` 5 rounds running while S3 lists every part) before the task parks with the server's code — the + dead upload is forgotten and the budget reset first, so a Retry starts fresh; books over 10 GiB park as + `file_too_large`. The source is the temp hard link, else the book's + current Processed file found by uuid (`LibrarySyncProtocol.fetchRelativePath(forUuid:)`) — never deleted. A + cellular-setting change cancels the parts in flight so they resend through the session that now applies. + `item_not_found` from the upload routes is not parked the first time: the book is re-registered through the sync + lane from its CURRENT state (`LibrarySyncProtocol.fetchSyncableItem(forUuid:)`) — item, external resources, + bookmarks, like `SyncService.handleItemsToUpload` — before the task is popped, or dropped when it's gone locally + too. Always re-register (decided): a book deleted on another device mid-upload can come back. A SECOND + `item_not_found` for the same uuid in a session parks it (re-registering didn't help: e.g. another uuid holds + its key on the server). A Retry of that parked upload clears the mark (decided: the user asked for the upload), + so the book is registered once more instead of parking again. Only a MEDIA-SERVER link (`SyncableItem.mediaServerProviderName`, never Hardcover) marks an + `.upload` as provider-backed (its answer then schedules no file); such a book's file goes up once downloaded + through the sync-lane `externalResourceToDownload` job, which just schedules the upload (the old `external_set` + route is gone; `complete` marks the resources downloaded). +- **Missing-items pass** (`SyncService.runMissingItemsPass`; contract in bookplayer-api `docs/multipart-uploads.md`): + sends every local uuid in ONE `POST /v1/library/status` and gets `{ unknown, unsynced }` back. Unknown items + (no server row, active or deleted) are first matched by path INLINE (`POST /uuids`, conflicts adopted in the + library and the queue via `applyUuidConflicts`, items re-read) — `PUT /` at a path the server holds under no uuid + or another one would never store ours, and a queued match job could rewrite uuids while the registrations were + still being stored — then registered like an import (`handleItemsToUpload`, parents first). Unsynced books (PRO only: `accessPolicy[.uploadFile]`) go straight to the upload lane BY UUID + (`scheduleMissingFileUploads`, the Processed file as source), never re-PUT: a stale path would move them back. + It skips media-server books (no backfill), books without a local file or over 10 GiB, and any book with an upload + already queued, parked included (`storeFileUploadsIfAbsent`: checked and stored in one actor turn, one save). It IS + the first sync's registration step (`syncLibraryContents`: the pass, then a root pull that never deletes); `/keys` + is deprecated and no longer called. The first sync never clears queued work: it waits until the sync lane is + empty (the flag stays off, the next root refresh retries), and every run of the pass is single-flight. A run + remembers the sync session it began in: sync going off (`logout`, a lapse) bumps it and clears the flag in one + locked step, so a logout+sign-in during a pass can't let the old run mark the first sync done after its teardown + wiped what it queued. Registration reads bookmarks in batches on a background context (`getUserBookmarks`). A lapse (`updateSyncEnabled(false)`, or `setup` finding a signed-in account — + `getAccountId() != nil` — that isn't syncing: the entitlement expired while the app was closed, or a stale cached + read, which is harmless) clears `hasScheduledLibraryContents`, so coming back runs a first sync: books imported meanwhile aren't on the server, and a plain listing would delete + them. `scheduleMissingItemsIfNeeded()` runs it again on LITE → PRO (the `.accountUpdate` sink's `noteProAccess` + sets `missingItemsPassPending` on a change of `lastKnownProAccess` — its first reading, e.g. after an app update, + only seeds it — cleared only by a run that could queue files, i.e. once the queue had its PRO policy) and weekly + (`missingItemsPassLastRun`), only once the first sync ran, while sync is on and with an empty sync lane; a tier + change arriving during a weekly run runs right after it; only a pending (tier-change) run posts + `.bookUploadsQueued`. Its state lives in the `UserDefaults` given to `setup` (`.standard`; tests inject a suite). Phone only: `setup(runsMissingItemsPass:)` is true in + `AppServices` alone (the watch has downloaded files of its own); without it the first sync throws and the other + entry points return. Accepted: a pre-March-2026 item that another + device deleted under its own uuid, or none, reads as unknown and comes back. The Settings › Debug export is + local only (tree with uuids, no server call). - `SyncService.swift` is `@Observable`. **`isActive` is `public private(set)` and must be mutated only via `updateSyncEnabled(_:)` / `logout()`** (both hop to `@MainActor`). It is driven by `.logout` (→ teardown, clears scheduled-contents flag, resets jobs) and `.accountUpdate` (→ `updateSyncEnabled(hasSyncEnabled())`) notifications. A `teardownTask` is **awaited** at the top of the sync-contents entry points so a fast logout→login can't let a late `resetAllJobs()` wipe freshly-scheduled jobs — preserve this ordering. Every `schedule*` method short-circuits on `guard isActive`. -- **Sync = the `pro` entitlement only** (see below). Job types (`SyncJobType`): `upload, update, move, - renameFolder, delete, shallowDelete, setBookmark, deleteBookmark, uploadArtwork, matchUuid`. +- **Sync = the `pro` OR `lite` entitlement** (`hasSyncEnabled()`); `lite` gets DB-backed sync only — + S3 file uploads are gated per-job via `SyncQueueService.accessPolicy` (`.uploadFile` is pro-only, + `.externalUpdate` — progress pushes to the USER'S OWN media server — is available on every tier, + matching the Android app). **The media-server progress PULL is the opposite:** `ExternalProgressService` + gates both the on-play resume prompt and the list refresh on `accountService.hasSyncEnabled()` (lite/pro), + read live on every pull, and cancels an in-flight pull on an `.accountUpdate` that drops the entitlement — + free/plus users push to their own server but never see other devices' positions. Android must mirror this. + Job types (`SyncJobType`): `upload, update, move, renameFolder, delete, shallowDelete, setBookmark, + deleteBookmark, uploadArtwork, matchUuid, externalResource, externalResourceToDownload, deleteExternalResource` + (sync lane), `uploadFile` (upload lane), `externalUpdate` (provider lanes). - **Download verification:** `verifyDownloadedFile` rejects truncated files by comparing `AVURLAsset` duration to the stored duration (tolerance `max(2, expected*0.02)`); completion is broadcast only after verification. Don't skip it — it prevents promoting a truncated book. @@ -333,8 +509,9 @@ lines). It is the highest-risk file in the app. ### Subscriptions & entitlements — `Shared/Services/Account/AccountService.swift` -- RevenueCat entitlements `AccessLevel { free, plus, pro }`. `hasSyncEnabled()` = `pro` entitlement active; - `hasPlusAccess()` = `plus` OR `pro` (with a local `donationMade` fallback when cached info is nil). +- RevenueCat entitlements `AccessLevel { free, plus, lite, pro }`. `hasSyncEnabled()` = `pro` OR `lite` active + (pro wins when both are held); + `hasPlusAccess()` = `plus` OR `pro` OR `lite` (all paid tiers unlock plus perks; local `donationMade` fallback when cached info is nil). - **These are client-side cached RevenueCat reads — UX gating only.** Never let `hasSyncEnabled()`/`isActive` become the sole gate for a server-billed resource; the server validates the entitlement. Purchases are guarded by `AppEnvironment.isPurchaseEnabled` (disabled on TestFlight). @@ -452,8 +629,9 @@ The crash surfaces and invariants most likely to be broken by a change. (The ful so conflicts crash. 2. **CoreData model change without the full 5-step manual-migration ritual** (auto-inference is OFF) → crashes existing installs. -3. **SwiftData:** don't share a `ModelContext` across actors; the sync-queue lives behind the `SyncTasksStorage` - actor; `MigrationPlan.injectedCoreDataContext` must be set before the container is built. +3. **SwiftData:** don't share a `ModelContext` across actors; the sync-queue lives behind the + `SyncQueueRepository` actor; `MigrationPlan.injectedCoreDataContext` must be set before the container + is built. 4. **Retain cycles / Combine leaks:** missing `[weak self]` in a sink; an `AnyCancellable` not stored; a named subscription not `.cancel()`'d before rebind (`PlayerManager` depends on this). 5. **UI/state mutated off the main actor** without a `@MainActor` hop / `.receive(on: .main)`. diff --git a/IAP-Configuration.storekit b/IAP-Configuration.storekit index 1993f28e2..c666d1e75 100644 --- a/IAP-Configuration.storekit +++ b/IAP-Configuration.storekit @@ -1,4 +1,14 @@ { + "appPolicies" : { + "eula" : "", + "policies" : [ + { + "locale" : "en_US", + "policyText" : "", + "policyURL" : "" + } + ] + }, "identifier" : "B145B4D1", "nonRenewingSubscriptions" : [ @@ -96,55 +106,51 @@ } ], "settings" : { + "_askToBuyEnabled" : false, + "_billingGracePeriodEnabled" : false, + "_billingIssuesEnabled" : false, "_compatibilityTimeRate" : { "3" : 6 }, + "_disableDialogs" : false, "_failTransactionsEnabled" : false, "_locale" : "en_US", + "_renewalBillingIssuesEnabled" : false, "_storefront" : "USA", "_storeKitErrors" : [ { - "current" : null, "enabled" : false, "name" : "Load Products" }, { - "current" : null, "enabled" : false, "name" : "Purchase" }, { - "current" : null, "enabled" : false, "name" : "Verification" }, { - "current" : null, "enabled" : false, "name" : "App Store Sync" }, { - "current" : null, "enabled" : false, "name" : "Subscription Status" }, { - "current" : null, "enabled" : false, "name" : "App Transaction" }, { - "current" : null, "enabled" : false, "name" : "Manage Subscriptions Sheet" }, { - "current" : null, "enabled" : false, "name" : "Refund Request Sheet" }, { - "current" : null, "enabled" : false, "name" : "Offer Code Redeem Sheet" } @@ -170,7 +176,9 @@ "familyShareable" : false, "groupNumber" : 1, "internalID" : "404D4313", - "introductoryOffer" : null, + "introductoryOffers" : [ + + ], "localizations" : [ { "description" : "", @@ -182,7 +190,10 @@ "recurringSubscriptionPeriod" : "P1M", "referenceName" : "pro.subscription", "subscriptionGroupID" : "652B5FC0", - "type" : "RecurringSubscription" + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] }, { "adHocOffers" : [ @@ -195,7 +206,9 @@ "familyShareable" : false, "groupNumber" : 1, "internalID" : "FBD1E1A1", - "introductoryOffer" : null, + "introductoryOffers" : [ + + ], "localizations" : [ { "description" : "", @@ -207,7 +220,10 @@ "recurringSubscriptionPeriod" : "P1Y", "referenceName" : "pro.subscription.yearly", "subscriptionGroupID" : "652B5FC0", - "type" : "RecurringSubscription" + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] }, { "adHocOffers" : [ @@ -220,7 +236,9 @@ "familyShareable" : false, "groupNumber" : 1, "internalID" : "EE383201", - "introductoryOffer" : null, + "introductoryOffers" : [ + + ], "localizations" : [ { "description" : "", @@ -232,7 +250,10 @@ "recurringSubscriptionPeriod" : "P1M", "referenceName" : "subscription.support.1", "subscriptionGroupID" : "652B5FC0", - "type" : "RecurringSubscription" + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] }, { "adHocOffers" : [ @@ -245,7 +266,9 @@ "familyShareable" : false, "groupNumber" : 1, "internalID" : "FCF99805", - "introductoryOffer" : null, + "introductoryOffers" : [ + + ], "localizations" : [ { "description" : "", @@ -257,7 +280,10 @@ "recurringSubscriptionPeriod" : "P1M", "referenceName" : "subscription.support.2", "subscriptionGroupID" : "652B5FC0", - "type" : "RecurringSubscription" + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] }, { "adHocOffers" : [ @@ -270,7 +296,9 @@ "familyShareable" : false, "groupNumber" : 1, "internalID" : "B7B02088", - "introductoryOffer" : null, + "introductoryOffers" : [ + + ], "localizations" : [ { "description" : "", @@ -282,7 +310,10 @@ "recurringSubscriptionPeriod" : "P1M", "referenceName" : "subscription.support.3", "subscriptionGroupID" : "652B5FC0", - "type" : "RecurringSubscription" + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] }, { "adHocOffers" : [ @@ -295,7 +326,9 @@ "familyShareable" : false, "groupNumber" : 1, "internalID" : "570A9BCB", - "introductoryOffer" : null, + "introductoryOffers" : [ + + ], "localizations" : [ { "description" : "", @@ -307,7 +340,10 @@ "recurringSubscriptionPeriod" : "P1M", "referenceName" : "subscription.support.4", "subscriptionGroupID" : "652B5FC0", - "type" : "RecurringSubscription" + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] }, { "adHocOffers" : [ @@ -320,7 +356,9 @@ "familyShareable" : false, "groupNumber" : 1, "internalID" : "4F02881E", - "introductoryOffer" : null, + "introductoryOffers" : [ + + ], "localizations" : [ { "description" : "", @@ -332,7 +370,10 @@ "recurringSubscriptionPeriod" : "P1M", "referenceName" : "subscription.support.6", "subscriptionGroupID" : "652B5FC0", - "type" : "RecurringSubscription" + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] }, { "adHocOffers" : [ @@ -345,7 +386,9 @@ "familyShareable" : false, "groupNumber" : 1, "internalID" : "2DCC769A", - "introductoryOffer" : null, + "introductoryOffers" : [ + + ], "localizations" : [ { "description" : "", @@ -357,7 +400,10 @@ "recurringSubscriptionPeriod" : "P1M", "referenceName" : "subscription.support.7", "subscriptionGroupID" : "652B5FC0", - "type" : "RecurringSubscription" + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] }, { "adHocOffers" : [ @@ -370,7 +416,9 @@ "familyShareable" : false, "groupNumber" : 1, "internalID" : "A977972B", - "introductoryOffer" : null, + "introductoryOffers" : [ + + ], "localizations" : [ { "description" : "", @@ -382,7 +430,10 @@ "recurringSubscriptionPeriod" : "P1M", "referenceName" : "subscription.support.8", "subscriptionGroupID" : "652B5FC0", - "type" : "RecurringSubscription" + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] }, { "adHocOffers" : [ @@ -395,7 +446,9 @@ "familyShareable" : false, "groupNumber" : 1, "internalID" : "9410B8C0", - "introductoryOffer" : null, + "introductoryOffers" : [ + + ], "localizations" : [ { "description" : "", @@ -407,7 +460,10 @@ "recurringSubscriptionPeriod" : "P1M", "referenceName" : "subscription.support.9", "subscriptionGroupID" : "652B5FC0", - "type" : "RecurringSubscription" + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] }, { "adHocOffers" : [ @@ -420,7 +476,9 @@ "familyShareable" : false, "groupNumber" : 1, "internalID" : "0DEF0B7A", - "introductoryOffer" : null, + "introductoryOffers" : [ + + ], "localizations" : [ { "description" : "", @@ -432,13 +490,76 @@ "recurringSubscriptionPeriod" : "P1M", "referenceName" : "subscription.support.10", "subscriptionGroupID" : "652B5FC0", - "type" : "RecurringSubscription" + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "2.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "0E8484DC", + "introductoryOffers" : [ + + ], + "localizations" : [ + { + "description" : "", + "displayName" : "", + "locale" : "en_US" + } + ], + "productID" : "com.tortugapower.audiobookplayer.subscription.lite", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "lite.subscription", + "subscriptionGroupID" : "652B5FC0", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "29.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "CF2B5AFC", + "introductoryOffers" : [ + + ], + "localizations" : [ + { + "description" : "", + "displayName" : "", + "locale" : "en_US" + } + ], + "productID" : "com.tortugapower.audiobookplayer.subscription.lite.yearly", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "lite.subscription.yearly", + "subscriptionGroupID" : "652B5FC0", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] } ] } ], "version" : { - "major" : 3, + "major" : 5, "minor" : 0 } } diff --git a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsData.swift b/Shared/AudiobookShelf/AudiobookShelfAudiobookDetailsData.swift similarity index 80% rename from BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsData.swift rename to Shared/AudiobookShelf/AudiobookShelfAudiobookDetailsData.swift index 6a0ea408f..9509d03b1 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/Details/AudiobookShelfAudiobookDetailsData.swift +++ b/Shared/AudiobookShelf/AudiobookShelfAudiobookDetailsData.swift @@ -6,40 +6,40 @@ // Copyright © 2025 BookPlayer LLC. All rights reserved. // -import BookPlayerKit import Foundation -struct AudiobookShelfAudiobookDetailsData: IntegrationDetailsDataProtocol { - let artist: String? - let narrator: String? - let filePath: String? - let fileSize: Int64? - let overview: String? - let runtimeInSeconds: TimeInterval? - let genres: [String]? - let tags: [String]? - let publishedYear: String? - let publisher: String? - let series: [Series]? +public struct AudiobookShelfAudiobookDetailsData: IntegrationDetailsDataProtocol { + public let id: String = UUID().uuidString + public let artist: String? + public let narrator: String? + public let filePath: String? + public let fileSize: Int64? + public let overview: String? + public let runtimeInSeconds: TimeInterval? + public let genres: [String]? + public let tags: [String]? + public let publishedYear: String? + public let publisher: String? + public let series: [Series]? - struct Series: Identifiable, Hashable { - let id: String - let name: String - let sequence: String? + public struct Series: Identifiable, Hashable { + public let id: String + public let name: String + public let sequence: String? } - var runtimeString: String { + public var runtimeString: String { guard let runtime = runtimeInSeconds else { return "runtime_unknown".localized } return TimeParser.formatTotalDuration(runtime) } - var seriesEntries: [IntegrationSeriesEntry] { + public var seriesEntries: [IntegrationSeriesEntry] { (series ?? []).map { IntegrationSeriesEntry(id: $0.id, name: $0.name, sequence: $0.sequence) } } - var fileSizeString: String { + public var fileSizeString: String { guard let size = fileSize else { return "file_size_unknown".localized } @@ -52,7 +52,7 @@ struct AudiobookShelfAudiobookDetailsData: IntegrationDetailsDataProtocol { // MARK: - API Response Model -struct AudiobookShelfItemDetailsResponse: Codable { +public struct AudiobookShelfItemDetailsResponse: Codable { let id: String let libraryId: String let media: Media diff --git a/Shared/AudiobookShelf/AudiobookShelfLibraryItem.swift b/Shared/AudiobookShelf/AudiobookShelfLibraryItem.swift new file mode 100644 index 000000000..594632755 --- /dev/null +++ b/Shared/AudiobookShelf/AudiobookShelfLibraryItem.swift @@ -0,0 +1,521 @@ +// +// AudiobookShelfLibraryItem.swift +// BookPlayer +// +// Created by Gianni Carlo on 14/11/25. +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import Foundation + +public struct AudiobookShelfSeriesReference: Codable, Hashable { + let id: String + let name: String + let sequence: String? +} + +public struct AudiobookShelfLibraryItem: IntegrationLibraryItemProtocol, Codable { + public enum Kind: String, Codable { + case audiobook = "book" + case library = "library" + case browseCategory = "browseCategory" + case series = "series" + case collection = "collection" + case author = "author" + case narrator = "narrator" + } + + public let id: String + public let title: String + public let kind: Kind + public let libraryId: String + + // Metadata + public let authorName: String? + public let narratorName: String? + public let duration: TimeInterval? + public let size: Int64? + public let subtitle: String? + public let series: [AudiobookShelfSeriesReference]? + public let addedAt: Int64? + public let updatedAt: Int64? + public let fileExtension: String? + + // Cover image + public let coverPath: String? + public let coverItemId: String? + + // Progress (if included) + public let progress: Double? + public let currentTime: TimeInterval? + public let isFinished: Bool? + /// The server's chapter list, empty unless this item came from an EXPANDED payload. + /// A streamed item has no other source: nothing opens the file to read embedded ones, + /// and ABS chapters can be server-side edits that aren't in the file at all. + public let chapters: [ChapterMetadata] + /// From the progress payload's lastUpdate (ms epoch) — drives the resume-playback + /// prompt's date comparison, same as Jellyfin's lastPlayedDate + public let lastPlayedDate: Date? + + // Browse metadata + public let browseCategory: AudiobookShelfBrowseCategory? + public let filter: AudiobookShelfItemFilter? + + public init( + id: String, + title: String, + kind: Kind, + libraryId: String, + authorName: String? = nil, + narratorName: String? = nil, + duration: TimeInterval? = nil, + size: Int64? = nil, + subtitle: String? = nil, + series: [AudiobookShelfSeriesReference]? = nil, + addedAt: Int64? = nil, + fileExtension: String? = nil, + updatedAt: Int64? = nil, + coverPath: String? = nil, + coverItemId: String? = nil, + progress: Double? = nil, + currentTime: TimeInterval? = nil, + isFinished: Bool? = nil, + lastPlayedDate: Date? = nil, + chapters: [ChapterMetadata] = [], + browseCategory: AudiobookShelfBrowseCategory? = nil, + filter: AudiobookShelfItemFilter? = nil + ) { + self.id = id + self.title = title + self.kind = kind + self.libraryId = libraryId + self.authorName = authorName + self.narratorName = narratorName + self.duration = duration + self.size = size + self.subtitle = subtitle + self.series = series + self.fileExtension = fileExtension + self.addedAt = addedAt + self.updatedAt = updatedAt + self.coverPath = coverPath + self.coverItemId = coverItemId + self.progress = progress + self.currentTime = currentTime + self.isFinished = isFinished + self.lastPlayedDate = lastPlayedDate + self.chapters = chapters + self.browseCategory = browseCategory + self.filter = filter + } +} + +extension AudiobookShelfLibraryItem { + public var displayName: String { title } + + public var isDownloadable: Bool { + kind == .audiobook + } + + public var isNavigable: Bool { + !isDownloadable + } + + public var placeholderImageName: String { + switch kind { + case .audiobook: "waveform" + case .library: "folder" + case .browseCategory: + switch browseCategory { + case .books: "books.vertical" + case .series: "rectangle.stack" + case .collections: "square.stack.3d.up" + case .authors: "person.2" + case .narrators: "mic" + case .none: "square.grid.2x2" + } + case .series: "rectangle.stack" + case .collection: "square.stack.3d.up" + case .author: "person" + case .narrator: "mic" + } + } + + public func seriesSequence(for seriesID: String) -> String? { + series?.first(where: { $0.id == seriesID })?.sequence + } + + public init(library: AudiobookShelfLibrary) { + self.init( + id: library.id, + title: library.name, + kind: .library, + libraryId: library.id, + subtitle: library.mediaType == "podcast" ? "Podcast library" : "Audiobook library" + ) + } + + public init(category: AudiobookShelfBrowseCategory, libraryId: String) { + self.init( + id: category.rawValue, + title: category.title, + kind: .browseCategory, + libraryId: libraryId, + subtitle: "Browse by \(category.title.lowercased())", + browseCategory: category + ) + } + + public init(author: AudiobookShelfLibraryFilterData.NamedEntity, libraryId: String) { + self.init( + id: author.id, + title: author.name, + kind: .author, + libraryId: libraryId, + subtitle: "Author", + filter: AudiobookShelfItemFilter(group: .authors, value: author.id, title: author.name) + ) + } + + public init(series: AudiobookShelfLibraryFilterData.NamedEntity, libraryId: String) { + self.init( + id: series.id, + title: series.name, + kind: .series, + libraryId: libraryId, + subtitle: "Series", + filter: AudiobookShelfItemFilter(group: .series, value: series.id, title: series.name) + ) + } + + public init(narrator: String, libraryId: String) { + self.init( + id: narrator, + title: narrator, + kind: .narrator, + libraryId: libraryId, + subtitle: "Narrator", + filter: AudiobookShelfItemFilter(group: .narrators, value: narrator, title: narrator) + ) + } + + public init(collection: AudiobookShelfCollection) { + self.init( + id: collection.id, + title: collection.name, + kind: .collection, + libraryId: collection.libraryId, + subtitle: collection.description ?? "\(collection.books.count) books", + coverItemId: collection.books.first?.id + ) + } + + public init?(apiItem: AudiobookShelfAPIItem) { + guard let mediaType = apiItem.mediaType, + let kind = Kind(rawValue: mediaType) else { + return nil + } + self.init( + id: apiItem.id, + title: apiItem.media.metadata.title, + kind: kind, + libraryId: apiItem.libraryId, + authorName: apiItem.media.metadata.primaryAuthorName, + narratorName: apiItem.media.metadata.primaryNarratorName, + duration: apiItem.media.duration, + size: apiItem.size, + series: apiItem.media.metadata.series, + addedAt: apiItem.addedAt, + fileExtension: apiItem.media.audioFiles?.first?.normalizedExtension, + updatedAt: apiItem.updatedAt, + coverPath: apiItem.media.coverPath, + progress: apiItem.userMediaProgress?.progress, + currentTime: apiItem.userMediaProgress?.currentTime, + isFinished: apiItem.userMediaProgress?.isFinished, + chapters: Self.chapterMetadata(from: apiItem.media.chapters, duration: apiItem.media.duration) + ) + } + + /// ABS gives every chapter a start AND an end, so durations are direct — but the list is + /// taken verbatim from the media and need not reach the item's end: an m4b whose embedded + /// chapter track stops before the trailing silence or credits leaves a gap, since + /// `media.duration` sums the audio files while `media.chapters` does not. + /// + /// The LAST chapter is therefore stretched to the item duration, giving the same total + /// coverage the Jellyfin mapping has by construction. Without it a position in that gap + /// resolves to no chapter, and `PlayableItem.init` falls back to `chapters[0]` — pinning + /// the whole session to chapter 1, which then never advances (the tick only reassigns when + /// `getChapter` finds one) and never reaches `chapters.last`, so the book never completes. + /// + /// Entries that don't describe a positive span are dropped: `getPlayableChapters` filters + /// those anyway, and storing them would make the stored list disagree with the playable one. + static func chapterMetadata( + from chapters: [AudiobookShelfAPIItem.Media.Chapter]?, + duration: TimeInterval? + ) -> [ChapterMetadata] { + guard let chapters else { return [] } + + // Degenerate entries go FIRST, so the stretch below lands on a real chapter — stretching + // whatever happened to sort last would resurrect a zero-length one and overlap its + // predecessor. + let usable = chapters + .filter { chapter in + guard chapter.end > chapter.start else { return false } + guard let duration else { return true } + return chapter.start < duration + } + .sorted { $0.start < $1.start } + + return usable.enumerated().map { index, chapter in + let isLast = index == usable.count - 1 + let end: TimeInterval + if let duration, isLast || chapter.end > duration { + end = duration + } else { + end = chapter.end + } + + return ChapterMetadata( + title: chapter.title, + start: chapter.start, + duration: end - chapter.start, + index: index + 1 + ) + } + } + + public init(progressItem: AudiobookShelfAPIItem.UserMediaProgress) { + self.init( + id: "", + title: "", + kind: Kind.audiobook, + libraryId: "", + progress: progressItem.progress, + currentTime: progressItem.currentTime, + isFinished: progressItem.isFinished, + lastPlayedDate: progressItem.lastUpdate.map { Date(timeIntervalSince1970: $0 / 1000) } + ) + } +} + +// MARK: - API Response Models + +public struct AudiobookShelfAPIItem: Codable { + public let id: String + public let libraryId: String + public let addedAt: Int64? + public let updatedAt: Int64? + public let mediaType: String? + public let media: Media + public let size: Int64? + public let userMediaProgress: UserMediaProgress? + public let relPath: String? + + public struct Media: Codable { + public let metadata: Metadata + public let coverPath: String? + public let duration: TimeInterval? + public let audioFiles: [AudioFile]? + /// Present on EXPANDED media only, alongside `audioFiles` — which is exactly what + /// `POST /api/items/batch/get` returns. These are the server's chapters, which the + /// user may have edited in ABS and which a multi-file book has instead of embedded + /// ones, so they are the authoritative list for an item we only ever stream. + public let chapters: [Chapter]? + + /// Only the fields we read, like `AudioFile` below: a malformed element we never look at + /// would otherwise throw and take the whole batch response — and the import with it. + public struct Chapter: Codable { + public let start: TimeInterval + public let end: TimeInterval + public let title: String + } + + public struct Metadata: Codable { + public let title: String + public let authorName: String? + public let narratorName: String? + public let authors: [NamedEntity]? + public let narrators: [String]? + public let series: [AudiobookShelfSeriesReference]? + + public enum CodingKeys: String, CodingKey { + case title + case authorName + case narratorName + case authors + case narrators + case series + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + title = try container.decode(String.self, forKey: .title) + authorName = try container.decodeIfPresent(String.self, forKey: .authorName) + narratorName = try container.decodeIfPresent(String.self, forKey: .narratorName) + authors = try container.decodeIfPresent([NamedEntity].self, forKey: .authors) + narrators = try container.decodeIfPresent([String].self, forKey: .narrators) + + if let seriesArray = try? container.decode([AudiobookShelfSeriesReference].self, forKey: .series) { + series = seriesArray + } else if let seriesSingle = try? container.decode(AudiobookShelfSeriesReference.self, forKey: .series) { + series = [seriesSingle] + } else { + series = nil + } + } + + public var primaryAuthorName: String? { + authorName ?? authors?.first?.name + } + + public var primaryNarratorName: String? { + narratorName ?? narrators?.first + } + } + + public struct NamedEntity: Codable { + public let id: String + public let name: String + } + + public struct AudioFile: Codable { + // ABS nests file fields under metadata (AudioFile.toJSON in the server: + // { index, ino, metadata: { filename, ext, path, ... }, ... }) — a top-level + // filename/ext shape can never decode a real expanded payload. + public let metadata: FileMetadata + + public struct FileMetadata: Codable { + public let filename: String + /// Dot-prefixed on the wire (".m4b") — normalize via `normalizedExtension`. + public let ext: String + } + } + } + + public struct UserMediaProgress: Codable { + public let progress: Double + public let currentTime: TimeInterval + public let isFinished: Bool + /// Milliseconds since epoch of the last progress update + public let lastUpdate: Double? + } +} + +public struct AudiobookShelfItemsResponse: Codable { + public let results: [AudiobookShelfAPIItem] + public let total: Int + public let limit: Int? + public let page: Int? +} + +/// Response of POST /api/items/batch/get — expanded items incl. media.audioFiles +public struct AudiobookShelfBatchItemsResponse: Codable { + public let libraryItems: [AudiobookShelfAPIItem]? +} + +public struct AudiobookShelfSearchResponse: Codable { + public let book: [SearchResult] + + public struct SearchResult: Codable { + public let libraryItem: AudiobookShelfAPIItem + } +} + +/// Response from `GET /api/authors/:id?include=items` (the endpoint the official +/// Vue web client uses for the author-detail page). `libraryItems` is hydrated +/// directly from the author record rather than via the `bookAuthors` join, which +/// avoids orphan-row matches that can occur after ABS dedups authors on import. +public struct AudiobookShelfAuthorWithItemsResponse: Codable { + public let id: String + public let name: String + public let libraryItems: [AudiobookShelfAPIItem]? +} + +public struct AudiobookShelfLibraryFilterData: Codable { + public let authors: [NamedEntity] + public let genres: [String] + public let tags: [String] + public let series: [NamedEntity] + public let narrators: [String] + public let languages: [String] + + public struct NamedEntity: Codable, Hashable { + public let id: String + public let name: String + } +} + +public struct AudiobookShelfCollection: Codable { + public let id: String + public let libraryId: String + public let name: String + public let description: String? + public let books: [AudiobookShelfAPIItem] +} + +public struct AudiobookShelfCollectionsResponse: Codable { + public let results: [AudiobookShelfCollection] +} + +// MARK: - Virtual import + +extension AudiobookShelfLibraryItem { + /// Builds the virtual-import payload for this item. The file extension and the duration + /// are REQUIRED and both come from hydration (`VirtualImportPipeline`): list endpoints + /// return minified items without audio-file metadata, so callers hydrate the selection + /// via `fetchItems(ids:)` (POST /api/items/batch/get) and SKIP items that have neither + /// a real extension nor a measured length — the extension is never guessed, and the + /// duration is never defaulted to 0, which would import an unplayable row. + @MainActor + public func asVirtualImportResource( + fileExtension: String, + duration: TimeInterval, + chapters: [ChapterMetadata] = [], + connectionService: AudiobookShelfConnectionService, + artworkSize: CGSize + ) -> SimpleExternalResource { + let libraryItem = SimpleLibraryItem( + title: title, + details: authorName ?? "voiceover_unknown_author".localized, + speed: 1, + currentTime: Double(currentTime ?? 0), + duration: duration, + /// ABS reports progress as a 0-1 fraction; the duration gate the old expression + /// carried is now the pipeline's precondition. + percentCompleted: max(progress ?? 0, 0) * 100, + isFinished: isFinished ?? false, + relativePath: "", + remoteURL: nil, + artworkURL: connectionService.createItemImageURL(self, size: artworkSize), + orderRank: 0, + parentFolder: nil, + originalFileName: "\(title).\(fileExtension)", + lastPlayDate: nil, + type: .book, + uuid: UUID().uuidString + ) + + return SimpleExternalResource( + id: abs(UUID().hashValue), // unique per element — a shared timestamp collides Identifiable ids within a batch + providerName: ExternalResource.ProviderName.audiobookshelf.rawValue, + providerId: id, + syncStatus: ExternalResource.SyncStatus.stream.rawValue, + lastSyncedAt: nil, + hostId: connectionService.connection?.stableHostId, + libraryItem: libraryItem, + chapters: chapters + ) + } +} + +extension AudiobookShelfAPIItem.Media.AudioFile { + /// ABS's `metadata.ext` is dot-prefixed (".m4b", server FileMetadata semantics); + /// BookPlayer composes filenames as "title.ext", so the dot must be stripped or + /// every ABS virtual import is named "Title..m4b". Empty ext maps to nil so the + /// import pipeline's skip contract (no extension = not importable) still holds. + public var normalizedExtension: String? { + let trimmed = metadata.ext.hasPrefix(".") ? String(metadata.ext.dropFirst()) : metadata.ext + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryLevelData.swift b/Shared/AudiobookShelf/AudiobookShelfLibraryLevelData.swift similarity index 66% rename from BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryLevelData.swift rename to Shared/AudiobookShelf/AudiobookShelfLibraryLevelData.swift index fe61bbf5a..c4d9047c6 100644 --- a/BookPlayer/AudiobookShelf/Library Screen/AudiobookShelfLibraryLevelData.swift +++ b/Shared/AudiobookShelf/AudiobookShelfLibraryLevelData.swift @@ -8,14 +8,14 @@ import Foundation -enum AudiobookShelfBrowseCategory: String, CaseIterable, Codable, Hashable { +public enum AudiobookShelfBrowseCategory: String, CaseIterable, Codable, Hashable { case books case series case collections case authors case narrators - var title: String { + public var title: String { switch self { case .books: "Books" case .series: "Series" @@ -26,30 +26,30 @@ enum AudiobookShelfBrowseCategory: String, CaseIterable, Codable, Hashable { } } -enum AudiobookShelfItemFilterGroup: String, Codable, Hashable { +public enum AudiobookShelfItemFilterGroup: String, Codable, Hashable { case authors case series case narrators } -struct AudiobookShelfItemFilter: Codable, Hashable { - let group: AudiobookShelfItemFilterGroup - let value: String - let title: String +public struct AudiobookShelfItemFilter: Codable, Hashable { + public let group: AudiobookShelfItemFilterGroup + public let value: String + public let title: String - var queryValue: String { + public var queryValue: String { let base64Value = Data(value.utf8).base64EncodedString() return "\(group.rawValue).\(base64Value)" } } -enum AudiobookShelfLibraryViewSource: Equatable, Hashable { +public enum AudiobookShelfLibraryViewSource: Equatable, Hashable { case libraries case books(libraryID: String, filter: AudiobookShelfItemFilter?) case entities(libraryID: String, category: AudiobookShelfBrowseCategory) case collection(id: String) - var libraryID: String { + public var libraryID: String { switch self { case .libraries: "" case .books(let libraryID, _): libraryID @@ -59,7 +59,7 @@ enum AudiobookShelfLibraryViewSource: Equatable, Hashable { } } -enum AudiobookShelfLibraryLevelData: Equatable, Hashable { +public enum AudiobookShelfLibraryLevelData: Equatable, Hashable { case library(source: AudiobookShelfLibraryViewSource, title: String) case details(data: AudiobookShelfLibraryItem) } diff --git a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift b/Shared/AudiobookShelf/Network/AudiobookShelfConnectionData.swift similarity index 57% rename from BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift rename to Shared/AudiobookShelf/Network/AudiobookShelfConnectionData.swift index cdf98eb34..0d8681b97 100644 --- a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift +++ b/Shared/AudiobookShelf/Network/AudiobookShelfConnectionData.swift @@ -8,22 +8,24 @@ import Foundation -struct AudiobookShelfConnectionData: Codable, Identifiable { - let id: String - let url: URL - let serverName: String - let userID: String - let userName: String - let apiToken: String - var selectedLibraryId: String? - var customHeaders: [String: String] = [:] +public struct AudiobookShelfConnectionData: Codable, Identifiable, IntegrationHostIdentifiable { + public let id: String + public let serverId: String? + public let url: URL + public let serverName: String + public let userID: String + public let userName: String + public let apiToken: String + public var selectedLibraryId: String? + public var customHeaders: [String: String] = [:] - enum CodingKeys: String, CodingKey { - case id, url, serverName, userID, userName, apiToken, selectedLibraryId, customHeaders + public enum CodingKeys: String, CodingKey { + case id, serverId, url, serverName, userID, userName, apiToken, selectedLibraryId, customHeaders } - init( + public init( id: String = UUID().uuidString, + serverId: String? = nil, url: URL, serverName: String, userID: String, @@ -33,6 +35,7 @@ struct AudiobookShelfConnectionData: Codable, Identifiable { customHeaders: [String: String] = [:] ) { self.id = id + self.serverId = serverId self.url = url self.serverName = serverName self.userID = userID @@ -42,9 +45,10 @@ struct AudiobookShelfConnectionData: Codable, Identifiable { self.customHeaders = customHeaders } - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + self.serverId = try? container.decodeIfPresent(String.self, forKey: .serverId) self.url = try container.decode(URL.self, forKey: .url) self.serverName = try container.decode(String.self, forKey: .serverName) self.userID = try container.decode(String.self, forKey: .userID) @@ -62,8 +66,16 @@ extension AudiobookShelfConnectionData: IntegrationConnectionPayload { } extension AudiobookShelfConnectionData: CustomDebugStringConvertible { - var debugDescription: String { + public var debugDescription: String { let apiTokenDebugDesc = apiToken.isEmpty ? "" : "" - return "AudiobookShelfConnectionData(\(url), \(serverName), \(userID), \(userName), \(apiTokenDebugDesc))" + let serverIdDesc = serverId ?? "" + return "AudiobookShelfConnectionData(\(url), serverId: \(serverIdDesc), \(serverName), \(userID), \(userName), \(apiTokenDebugDesc))" + } + + public func buildAudiobookshelfDownloadUrl(providerId: String) -> String { + // appendingPathComponent normalizes the trailing slash a user may have typed in + // the server URL — string concatenation yields "host//api/…", which some reverse + // proxies (nginx/Cloudflare) 404 + return self.url.appendingPathComponent("api/items/\(providerId)/download").absoluteString } } diff --git a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift b/Shared/AudiobookShelf/Network/AudiobookShelfConnectionService.swift similarity index 79% rename from BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift rename to Shared/AudiobookShelf/Network/AudiobookShelfConnectionService.swift index f1e45bca9..e10fdb123 100644 --- a/BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift +++ b/Shared/AudiobookShelf/Network/AudiobookShelfConnectionService.swift @@ -6,12 +6,11 @@ // Copyright © 2025 BookPlayer LLC. All rights reserved. // -import BookPlayerKit import Foundation @MainActor @Observable -class AudiobookShelfConnectionService: BPLogger { +public class AudiobookShelfConnectionService: BPLogger { /// `nonisolated` so the `nonisolated init` below can read it without crossing isolation. private nonisolated static let activeConnectionIDKey = "audiobookshelf_active_connection_id" @@ -19,22 +18,54 @@ class AudiobookShelfConnectionService: BPLogger { /// Reads below forward into it so views keep observing through this service. private let store: IntegrationConnectionStore - var connections: [AudiobookShelfConnectionData] { store.connections } - var connection: AudiobookShelfConnectionData? { store.active } + public var connections: [AudiobookShelfConnectionData] { store.connections } + /// A connection pinned by the sync/stream engine for a specific external resource — + /// overrides the UI's active selection until cleared. Set via ``useConnection(_:)``. + private var manualConnection: AudiobookShelfConnectionData? + public var connection: AudiobookShelfConnectionData? { + if let manualConnection { return manualConnection } + return store.active + } + + /// Pins `connection` (used when consuming a stream for a resource whose host resolved + /// to a non-active saved server). ABS clients are stateless, so no rebuild is needed. + public func useConnection(_ connection: AudiobookShelfConnectionData) { + manualConnection = connection + } private let urlSession: URLSession /// Redirect-aware HTTP client for the OIDC handshake. Separate from `urlSession` because the /// handshake needs a client that can decline redirects *and* share one cookie jar across its two /// server calls — see ``AudiobookShelfOIDCFlow``. private nonisolated let httpClient: IntegrationHTTPClient +#if !os(watchOS) /// `nonisolated` (not `nonisolated(unsafe)`) because `WebAuthenticating` is `Sendable`: the compiler /// checks that holding one here is safe, instead of the safety resting on a comment that a future - /// edit could invalidate. + /// edit could invalidate. Browser-based SSO is iOS-only — the watch never signs in by itself. private nonisolated let webAuthenticator: WebAuthenticating +#endif - var activeConnectionID: String? { store.activeConnectionID } + public var activeConnectionID: String? { store.activeConnectionID } - nonisolated init( +#if os(watchOS) + public nonisolated init( + keychainService: KeychainServiceProtocol = KeychainService(), + httpClient: IntegrationHTTPClient = IntegrationURLSessionClient(), + defaults: UserDefaults = .standard + ) { + self.store = IntegrationConnectionStore( + keychainKey: .audiobookshelfConnection, + activeIDDefaultsKey: Self.activeConnectionIDKey, + keychain: keychainService, + defaults: defaults + ) + self.httpClient = httpClient + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 15 + self.urlSession = URLSession(configuration: configuration) + } +#else + public nonisolated init( keychainService: KeychainServiceProtocol = KeychainService(), httpClient: IntegrationHTTPClient = IntegrationURLSessionClient(), webAuthenticator: WebAuthenticating = WebAuthenticationSession(), @@ -52,8 +83,9 @@ class AudiobookShelfConnectionService: BPLogger { configuration.timeoutIntervalForRequest = 15 self.urlSession = URLSession(configuration: configuration) } +#endif - func setup() { + public func setup() { store.reload() } @@ -99,17 +131,22 @@ class AudiobookShelfConnectionService: BPLogger { } /// What a server told us it can do, so the UI only offers sign-in methods that can actually work. - struct ServerCapabilities: Equatable { - var supportsOIDC: Bool = false + public struct ServerCapabilities: Equatable { + public init(supportsOIDC: Bool = false, oidcButtonText: String? = nil, supportsLocal: Bool = true) { + self.supportsOIDC = supportsOIDC + self.oidcButtonText = oidcButtonText + self.supportsLocal = supportsLocal + } + public var supportsOIDC: Bool = false /// The provider button label ABS's own web UI shows, e.g. "Login with OpenId". Preferring the /// server's wording means a user sees the same label here as in the browser. - var oidcButtonText: String? + public var oidcButtonText: String? /// Whether the server accepts username/password sign-in at all. ABS admins can disable local /// auth outright (SSO-only servers), and `authMethods` then omits `"local"` — a case the probe /// used to ignore, leaving those users a password form that cannot possibly work. Defaults to /// `true` because that is the failure-safe direction: when the probe can't answer, offering a /// password form that might work beats hiding the only sign-in path a server may have. - var supportsLocal: Bool = true + public var supportsLocal: Bool = true } /// Best-effort capability probe against `/status`. @@ -197,9 +234,15 @@ class AudiobookShelfConnectionService: BPLogger { throw IntegrationError.unexpectedResponse(code: nil) } + // The login response carries the instance's stable id in the "server" object — the + // cross-device host identity for synced external resources. Best-effort: absence must + // never fail the sign-in, and must not wipe a previously captured id. + let serverId = (json["server"] as? [String: Any])?["id"] as? String + persist( url: url, serverName: serverName, + serverId: serverId, userID: userID, userName: username, apiToken: apiToken, @@ -218,6 +261,7 @@ class AudiobookShelfConnectionService: BPLogger { private func persist( url: URL, serverName: String, + serverId: String? = nil, userID: String, userName: String, apiToken: String, @@ -227,6 +271,7 @@ class AudiobookShelfConnectionService: BPLogger { let result = store.upsert(url: url, userID: userID, replacingID: replacingID) { existing in AudiobookShelfConnectionData( id: existing?.id ?? UUID().uuidString, + serverId: serverId ?? existing?.serverId, url: url, serverName: serverName, userID: userID, @@ -244,6 +289,7 @@ class AudiobookShelfConnectionService: BPLogger { } } +#if !os(watchOS) /// Sign in via AudiobookShelf's native OpenID Connect ("SSO") flow and store the connection. /// /// The handshake itself lives in ``AudiobookShelfOIDCFlow`` — the hop ordering there is subtle and @@ -287,33 +333,35 @@ class AudiobookShelfConnectionService: BPLogger { ) } - func updateCustomHeaders(_ headers: [String: String]) { +#endif + + public func updateCustomHeaders(_ headers: [String: String]) { guard let activeID = connection?.id else { return } updateCustomHeaders(id: activeID, headers) } /// Persist `headers` to the connection with the given id, regardless of which is active. - func updateCustomHeaders(id: String, _ headers: [String: String]) { + public func updateCustomHeaders(id: String, _ headers: [String: String]) { store.updateCustomHeaders(id: id, headers) } - func saveSelectedLibrary(id: String?) { + public func saveSelectedLibrary(id: String?) { guard let activeID = connection?.id else { return } store.setSelectedLibrary(id: activeID, libraryId: id) } - func activateConnection(id: String) { + public func activateConnection(id: String) { store.setActive(id: id) } - func deleteConnection(id: String) { + public func deleteConnection(id: String) { // Revoke the dropped connection's token server-side so ABS doesn't accumulate orphans. if let removed = store.remove(id: id) { revokeTokenInBackground(connection: removed) } } - func deleteConnection() { + public func deleteConnection() { if let id = connection?.id { deleteConnection(id: id) } @@ -462,6 +510,37 @@ class AudiobookShelfConnectionService: BPLogger { return (authorResponse.libraryItems ?? []).compactMap { AudiobookShelfLibraryItem(apiItem: $0) } } + /// Hydrates the given item ids via POST /api/items/batch/get (one round-trip): list + /// endpoints return MINIFIED items without `audioFiles`, and virtual import requires + /// the REAL file extension — items without one are skipped by the importer, never + /// guessed. + public func fetchItems(ids: [String]) async throws -> [AudiobookShelfLibraryItem] { + guard !ids.isEmpty else { return [] } + guard let connection else { + throw URLError(.userAuthenticationRequired) + } + + let url = connection.url + .appendingPathComponent("api") + .appendingPathComponent("items") + .appendingPathComponent("batch") + .appendingPathComponent("get") + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = try JSONEncoder().encode(["libraryItemIds": ids]) + applyAuthenticatedHeaders(to: &request, connection: connection) + // After the auth headers: the integration's own values win on conflict, but + // Content-Type is ours to set + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await urlSession.data(for: request) + _ = try validateAuthenticatedResponse(response) + + let decoded = try JSONDecoder().decode(AudiobookShelfBatchItemsResponse.self, from: data) + return (decoded.libraryItems ?? []).compactMap { AudiobookShelfLibraryItem(apiItem: $0) } + } + public func fetchFilterData(in libraryId: String) async throws -> AudiobookShelfLibraryFilterData { guard let connection else { throw URLError(.userAuthenticationRequired) @@ -635,6 +714,56 @@ class AudiobookShelfConnectionService: BPLogger { .appendingPathComponent("download") } + /// Fetches the user's saved progress for one library item (`GET /api/me/progress/{id}`). + public func fetchItem(for id: String) async throws -> AudiobookShelfLibraryItem? { + guard let connection else { + throw URLError(.userAuthenticationRequired) + } + + let url = connection.url + .appendingPathComponent("api") + .appendingPathComponent("me") + .appendingPathComponent("progress") + .appendingPathComponent(id) + + var request = URLRequest(url: url) + applyAuthenticatedHeaders(to: &request, connection: connection) + let (data, response) = try await httpClient.data(for: request) + _ = try validateAuthenticatedResponse(response) + let progress = try JSONDecoder().decode(AudiobookShelfAPIItem.UserMediaProgress.self, from: data) + return AudiobookShelfLibraryItem(progressItem: progress) + } + + /// Pushes playback progress for one library item (`PATCH /api/me/progress/{id}`), mirroring + /// what the Android app's external-update task sends. + public func updateProgress( + for id: String, + progress: Double, + currentTime: Double + ) async throws { + guard let connection else { + throw URLError(.userAuthenticationRequired) + } + + let url = connection.url + .appendingPathComponent("api") + .appendingPathComponent("me") + .appendingPathComponent("progress") + .appendingPathComponent(id) + + var request = URLRequest(url: url) + request.httpMethod = "PATCH" + applyAuthenticatedHeaders(to: &request, connection: connection) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "progress": progress, + "currentTime": currentTime, + ]) + + let (_, response) = try await httpClient.data(for: request) + _ = try validateAuthenticatedResponse(response) + } + /// Returns a URLRequest for downloading a library item, carrying the bearer /// token via the standard `Authorization: Bearer` header plus the user-defined /// custom HTTP headers (needed for servers behind Cloudflare Access etc.). @@ -659,10 +788,12 @@ class AudiobookShelfConnectionService: BPLogger { Task { [urlSession] in var request = URLRequest(url: url) request.httpMethod = "POST" - request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") + // Custom headers first, bearer last — same precedence as applyAuthenticatedHeaders, + // so a user-defined header named Authorization can't clobber the token being revoked for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } + request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") _ = try? await urlSession.data(for: request) } } @@ -718,14 +849,17 @@ class AudiobookShelfConnectionService: BPLogger { } // AudiobookShelf image endpoint: /api/items/:id/cover - // Optional query params: width, height, format - var urlString = "\(baseURL.absoluteString)/api/items/\(itemID)/cover" - - // Add size parameters if needed - let width = Int(size.width) - let height = Int(size.height) - urlString += "?width=\(width)&height=\(height)&format=webp" + // appendingPathComponent normalizes a trailing slash in the stored server URL — + // string concatenation yields "host//api/…", which some reverse proxies 404 + // (same fix as the download-URL builders) + let coverURL = baseURL.appendingPathComponent("api/items/\(itemID)/cover") + var components = URLComponents(url: coverURL, resolvingAgainstBaseURL: false) + components?.queryItems = [ + URLQueryItem(name: "width", value: String(Int(size.width))), + URLQueryItem(name: "height", value: String(Int(size.height))), + URLQueryItem(name: "format", value: "webp"), + ] - return URL(string: urlString) + return components?.url } } diff --git a/Shared/AudiobookShelf/Network/AudiobookShelfLibrary.swift b/Shared/AudiobookShelf/Network/AudiobookShelfLibrary.swift new file mode 100644 index 000000000..d0afa2db6 --- /dev/null +++ b/Shared/AudiobookShelf/Network/AudiobookShelfLibrary.swift @@ -0,0 +1,29 @@ +// +// AudiobookShelfLibrary.swift +// BookPlayer +// +// Created by Gianni Carlo on 14/11/25. +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import Foundation + +public struct AudiobookShelfLibrary: Codable, Identifiable { + public let id: String + public let name: String + public let folders: [Folder] + public let displayOrder: Int + public let icon: String + public let mediaType: String // "book" or "podcast" + public let provider: String + + public struct Folder: Codable { + let id: String + let fullPath: String + let libraryId: String + } +} + +public struct AudiobookShelfLibrariesResponse: Codable { + let libraries: [AudiobookShelfLibrary] +} diff --git a/BookPlayer/AudiobookShelf/Network/AudiobookShelfOIDCFlow.swift b/Shared/AudiobookShelf/Network/AudiobookShelfOIDCFlow.swift similarity index 98% rename from BookPlayer/AudiobookShelf/Network/AudiobookShelfOIDCFlow.swift rename to Shared/AudiobookShelf/Network/AudiobookShelfOIDCFlow.swift index 10a7f30d0..c35041880 100644 --- a/BookPlayer/AudiobookShelf/Network/AudiobookShelfOIDCFlow.swift +++ b/Shared/AudiobookShelf/Network/AudiobookShelfOIDCFlow.swift @@ -6,7 +6,9 @@ // Copyright © 2026 BookPlayer LLC. All rights reserved. // -import BookPlayerKit +// The browser-based SSO handshake is iOS-only: ASWebAuthenticationSession presentation +// and UIKit anchors don't exist on watchOS, and the watch never signs in by itself. +#if !os(watchOS) import Foundation /// AudiobookShelf's native OpenID Connect ("SSO") handshake. @@ -347,3 +349,5 @@ struct AudiobookShelfOIDCFlow: BPLogger { } } } + +#endif diff --git a/Shared/BookPlayerError.swift b/Shared/BookPlayerError.swift index dd2f43c5f..72e2cf117 100644 --- a/Shared/BookPlayerError.swift +++ b/Shared/BookPlayerError.swift @@ -11,7 +11,8 @@ import Foundation public enum BookPlayerError: Error { case runtimeError(String) case networkError(String) - case networkErrorWithCode(message: String, code: String) + /// A 4xx carrying a machine-readable `error` code: the request can never succeed as sent + case networkErrorWithCode(message: String, code: String, status: Int) case cancelledTask case emptyResponse } @@ -25,7 +26,7 @@ extension BookPlayerError: LocalizedError { return "Empty network response" case .networkError(let message): return message - case .networkErrorWithCode(let message, _): + case .networkErrorWithCode(let message, _, _): return message case .cancelledTask: return "Concurrent task was cancelled" diff --git a/Shared/Constants.swift b/Shared/Constants.swift index 984c700c3..4ae43fcd7 100644 --- a/Shared/Constants.swift +++ b/Shared/Constants.swift @@ -15,6 +15,12 @@ public enum Constants { public static let syncTasksQueue = "userSyncTasksQueue" public static let lastSyncTimestamp = "lastSyncTimestamp" public static let hasScheduledLibraryContents = "hasScheduledLibraryContents" + /// When the missing-items pass last completed (the weekly run) + public static let missingItemsPassLastRun = "missingItemsPassLastRun" + /// The account became PRO: the next pass runs right away and starts the continued task + public static let missingItemsPassPending = "missingItemsPassPending" + /// The tier the last account update reported, to notice LITE → PRO + public static let lastKnownProAccess = "lastKnownProAccess" /// Armed when a book finishes playing; consumed on the next review-prompt evaluation public static let pendingReviewPrompt = "userPendingReviewPrompt" diff --git a/Shared/CoreData/Backed-Models/ExternalResource+CoreDataClass.swift b/Shared/CoreData/Backed-Models/ExternalResource+CoreDataClass.swift new file mode 100644 index 000000000..6409d34fc --- /dev/null +++ b/Shared/CoreData/Backed-Models/ExternalResource+CoreDataClass.swift @@ -0,0 +1,66 @@ +// +// ExternalResource+CoreDataClass.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 13/3/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation +import CoreData + +extension ExternalResource { + public enum SyncStatus: String, Codable { + case notSynced = "not_synced" + case stream = "stream" + case downloaded = "downloaded" + } + + /// A provider that is a server the user connects to: it has a host, an item can stream + /// from it, and progress can be pushed to it. The complement of a metadata service like + /// Hardcover, which has none of those. + /// + /// A type of its own rather than a subset of `ProviderName` so the compiler carries the + /// distinction: anything that only makes sense for a server — a stream URL, a host, the + /// glyph in the library row — takes this and has no case left to answer with a placeholder. + /// Deliberately NOT `RawRepresentable`: `ProviderName` owns the strings that reach Core + /// Data, so there is no second spelling of them to drift. + public enum MediaServerProvider { + case jellyfin + case audiobookshelf + } + + public enum ProviderName: String, Codable, CaseIterable { + case jellyfin + case hardcover + case audiobookshelf + + /// This provider as a media server, or nil for one that isn't. + /// + /// The switch has NO `default` on purpose: this is the one place the question is answered, + /// so adding a provider case becomes a compile error here and whoever adds it has to say + /// which side it falls on. Everything else derives from it — `isMediaServer`, + /// `SimpleExternalResource.mediaServer`, the SQL filter in + /// `LibraryService.findMediaServerResources(at:)`, and every switch that handles servers + /// only (stream source, host display, progress push, the row glyph), each of which then + /// gets the same compile error for the half of the question it answers. + public var mediaServer: MediaServerProvider? { + switch self { + case .jellyfin: .jellyfin + case .audiobookshelf: .audiobookshelf + case .hardcover: nil + } + } + + /// Convenience over `mediaServer` where only the yes/no matters. + public var isMediaServer: Bool { + mediaServer != nil + } + + /// The media-server providers as stored in `ExternalResource.providerName`, for an `IN` + /// predicate. + public static var mediaServerRawValues: [String] { + allCases.filter(\.isMediaServer).map(\.rawValue) + } + } +} diff --git a/Shared/CoreData/Backed-Models/ExternalResource+CoreDataProperties.swift b/Shared/CoreData/Backed-Models/ExternalResource+CoreDataProperties.swift new file mode 100644 index 000000000..db67f6209 --- /dev/null +++ b/Shared/CoreData/Backed-Models/ExternalResource+CoreDataProperties.swift @@ -0,0 +1,75 @@ +// +// ExternalResource+CoreDataProperties.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 13/3/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation +import CoreData + +@objc(ExternalResource) +public class ExternalResource: NSManagedObject { + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "ExternalResource") + } + + @NSManaged public var id: Int32 + @NSManaged public var providerName: String + @NSManaged public var providerId: String + @NSManaged public var syncStatus: String + @NSManaged public var lastSyncedAt: Date? + @NSManaged public var processedFile: Bool + @NSManaged public var hostId: String? + + @NSManaged public var libraryItem: LibraryItem? + + @nonobjc public class func create( + _ item: SimpleExternalResource, + libraryItem: LibraryItem?, + in context: NSManagedObjectContext + ) -> ExternalResource { + // swiftlint:disable:next force_cast + let entity = NSEntityDescription.insertNewObject(forEntityName: "ExternalResource", into: context) as! ExternalResource + + // truncatingIfNeeded: snapshot ids from the import builders are abs(UUID().hashValue) + // — almost always beyond Int32.max, and Int32(exactly-style init) would trap. The + // field is a local row marker, not identity (that's (providerName, providerId)). + entity.id = Int32(truncatingIfNeeded: item.id) + entity.providerName = item.providerName + entity.providerId = item.providerId + entity.lastSyncedAt = item.lastSyncedAt + entity.syncStatus = item.syncStatus + entity.processedFile = item.processedFile + entity.hostId = item.hostId + + if let item = libraryItem { + entity.libraryItem = item + } + + return entity + } + + @nonobjc public class func create( + _ item: SyncableExternalResource, + libraryItem: LibraryItem?, + in context: NSManagedObjectContext + ) -> ExternalResource { + // swiftlint:disable:next force_cast + let entity = NSEntityDescription.insertNewObject(forEntityName: "ExternalResource", into: context) as! ExternalResource + + entity.providerName = item.providerName + entity.providerId = item.providerId + entity.lastSyncedAt = item.lastSyncedAt + entity.syncStatus = item.syncStatus + entity.processedFile = item.processedFile + entity.hostId = item.hostId + + if let item = libraryItem { + entity.libraryItem = item + } + + return entity + } +} diff --git a/Shared/CoreData/Backed-Models/LibraryItem+CoreDataProperties.swift b/Shared/CoreData/Backed-Models/LibraryItem+CoreDataProperties.swift index 78e5da2b4..e50438d61 100644 --- a/Shared/CoreData/Backed-Models/LibraryItem+CoreDataProperties.swift +++ b/Shared/CoreData/Backed-Models/LibraryItem+CoreDataProperties.swift @@ -35,6 +35,30 @@ extension LibraryItem { @NSManaged public var details: String! @NSManaged public var type: ItemType @NSManaged public var hardcoverBook: HardcoverBook? + @NSManaged public var externalResources: NSSet? +} + +// MARK: Generated accessors for externalResources +extension LibraryItem { + @objc(addExternalResourcesObject:) + @NSManaged public func addToExternalResources(_ value: ExternalResource) + + @objc(removeExternalResourcesObject:) + @NSManaged public func removeFromExternalResources(_ value: ExternalResource) + + @objc(addExternalResources:) + @NSManaged public func addToExternalResources(_ values: NSSet) + + @objc(removeExternalResources:) + @NSManaged public func removeFromExternalResources(_ values: NSSet) + + public var resourcesArray: [ExternalResource] { + return externalResources?.allObjects as? [ExternalResource] ?? [] + } + + public var jellyfinResource: ExternalResource? { + return resourcesArray.first { $0.providerName == ExternalResource.ProviderName.jellyfin.rawValue } + } } // MARK: Generated accessors for bookmarks diff --git a/Shared/CoreData/BookPlayer.xcdatamodeld/.xccurrentversion b/Shared/CoreData/BookPlayer.xcdatamodeld/.xccurrentversion index 53134484e..6eed3fb59 100644 --- a/Shared/CoreData/BookPlayer.xcdatamodeld/.xccurrentversion +++ b/Shared/CoreData/BookPlayer.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Audiobook Player 11.xcdatamodel + Audiobook Player 12.xcdatamodel diff --git a/Shared/CoreData/BookPlayer.xcdatamodeld/Audiobook Player 12.xcdatamodel/contents b/Shared/CoreData/BookPlayer.xcdatamodeld/Audiobook Player 12.xcdatamodel/contents new file mode 100644 index 000000000..a3d6a6968 --- /dev/null +++ b/Shared/CoreData/BookPlayer.xcdatamodeld/Audiobook Player 12.xcdatamodel/contents @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Shared/CoreData/CoreDataStack.swift b/Shared/CoreData/CoreDataStack.swift index 4633f7f2e..19f4d8e14 100644 --- a/Shared/CoreData/CoreDataStack.swift +++ b/Shared/CoreData/CoreDataStack.swift @@ -67,11 +67,18 @@ public class CoreDataStack { } public func saveContext(_ context: NSManagedObjectContext) { - guard context.hasChanges else { return } - do { - try context.save() - } catch let error as NSError { - fatalError("Unresolved error \(error), \(error.userInfo)") + // performAndWait: the unified sync queue saves from arbitrary executor threads, and a + // save must run on the context's own queue. It also serializes concurrent saves of the + // SAME context — the only serialization CoreData needs here; different contexts save + // independently. Save failure remains a hard fail (repo invariant): with no merge + // policy set, a failed save means inconsistent state that must never be silently kept. + context.performAndWait { + guard context.hasChanges else { return } + do { + try context.save() + } catch let error as NSError { + fatalError("Unresolved error \(error), \(error.userInfo)") + } } } } diff --git a/Shared/CoreData/Lightweight-Models/PlayableChapter.swift b/Shared/CoreData/Lightweight-Models/PlayableChapter.swift index 15afd9b73..3dc3457ec 100644 --- a/Shared/CoreData/Lightweight-Models/PlayableChapter.swift +++ b/Shared/CoreData/Lightweight-Models/PlayableChapter.swift @@ -19,8 +19,15 @@ public struct PlayableChapter: Codable, Identifiable { public let duration: TimeInterval public let relativePath: String public let remoteURL: URL? + public let externalUrl: URL? public let index: Int16 public let chapterOffset: TimeInterval + public let externalHeaders: [String: String] + /// The item carries a media-server resource, but no saved connection on THIS device + /// matches its host — so there is no external URL to stream and nothing to download. + /// Distinct from `externalUrl == nil`, which is also true for items that never had a + /// media server at all. + public let hasUnresolvedExternalHost: Bool public var end: TimeInterval { return start + duration @@ -37,6 +44,17 @@ public struct PlayableChapter: Codable, Identifiable { URL(fileURLWithPath: relativePath).fileType?.conforms(to: .movie) ?? false } + /// The file isn't on disk and only a media server can supply it: either its stream URL + /// resolved (and the load still failed, so the token or the server is the problem) or no + /// saved connection matches its host, meaning the server was never added here. + /// + /// A function rather than a property because it stats the filesystem. + public func needsMediaServer() -> Bool { + guard !FileManager.default.fileExists(atPath: fileURL.path) else { return false } + + return externalUrl != nil || hasUnresolvedExternalHost + } + public init( title: String, author: String, @@ -44,8 +62,11 @@ public struct PlayableChapter: Codable, Identifiable { duration: TimeInterval, relativePath: String, remoteURL: URL?, + externalURL: URL?, index: Int16, - chapterOffset: TimeInterval = 0 + chapterOffset: TimeInterval = 0, + externalHeaders: [String: String] = [:], + hasUnresolvedExternalHost: Bool = false ) { self.title = title self.author = author @@ -53,8 +74,36 @@ public struct PlayableChapter: Codable, Identifiable { self.duration = duration self.relativePath = relativePath self.remoteURL = remoteURL + self.externalUrl = externalURL self.index = index self.chapterOffset = chapterOffset + self.externalHeaders = externalHeaders + self.hasUnresolvedExternalHost = hasUnresolvedExternalHost + } + + /// `externalUrl`/`externalHeaders`/`hasUnresolvedExternalHost` are deliberately EXCLUDED from Codable: the headers carry + /// the media server's live `Authorization` token, and encoded `PlayableItem`s travel through + /// the WatchConnectivity application context, which the system PERSISTS TO DISK on both + /// devices. Both values are per-device, resolved from the local connection at load time + /// (`PlaybackService.getPlayableChapters`), so there is nothing to transport — the receiving + /// side re-resolves against its own saved connections. + enum CodingKeys: String, CodingKey { + case title, author, start, duration, relativePath, remoteURL, index, chapterOffset + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.title = try container.decode(String.self, forKey: .title) + self.author = try container.decode(String.self, forKey: .author) + self.start = try container.decode(TimeInterval.self, forKey: .start) + self.duration = try container.decode(TimeInterval.self, forKey: .duration) + self.relativePath = try container.decode(String.self, forKey: .relativePath) + self.remoteURL = try container.decodeIfPresent(URL.self, forKey: .remoteURL) + self.index = try container.decode(Int16.self, forKey: .index) + self.chapterOffset = (try? container.decodeIfPresent(TimeInterval.self, forKey: .chapterOffset)) ?? 0 + self.externalUrl = nil + self.externalHeaders = [:] + self.hasUnresolvedExternalHost = false } } diff --git a/Shared/CoreData/Lightweight-Models/PlayableItem.swift b/Shared/CoreData/Lightweight-Models/PlayableItem.swift index 86440a42a..7c03c23b4 100644 --- a/Shared/CoreData/Lightweight-Models/PlayableItem.swift +++ b/Shared/CoreData/Lightweight-Models/PlayableItem.swift @@ -11,7 +11,11 @@ import Foundation public final class PlayableItem: NSObject, Identifiable { public var id: String { - return relativePath + // Legacy watch payloads predating the uuid field decode to a placeholder (either + // generation, or empty) — colliding Identifiable ids break SwiftUI lists, so fall + // back to the path. isRealUuid covers the whole placeholder triad. + if !Constants.isRealUuid(uuid) { return relativePath } + return uuid } public let title: String public let author: String diff --git a/Shared/CoreData/Lightweight-Models/SimpleExternalResource.swift b/Shared/CoreData/Lightweight-Models/SimpleExternalResource.swift new file mode 100644 index 000000000..4dea4aed1 --- /dev/null +++ b/Shared/CoreData/Lightweight-Models/SimpleExternalResource.swift @@ -0,0 +1,129 @@ +// +// SimpleExternalResource.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 13/3/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +public struct SimpleExternalResource: Identifiable, Equatable, Hashable { + public let id: Int + public let providerName: String + public let providerId: String + public let syncStatus: String + public var lastSyncedAt: Date? + public var processedFile = false + public var hostId: String? + public var libraryItemUuid: String? + public var libraryItemName: String? + public var libraryItem: SimpleLibraryItem? + /// Chapters the server reported for a virtual import: set by `asVirtualImportResource`, + /// consumed by `createExternalBook`. Import-only — `init(from: ExternalResource)` leaves it + /// empty, because the entity has no chapter column; the rows live on the Book. + public var chapters: [ChapterMetadata] = [] + /// Whether this item's book still has no chapters, so a refresh knows to ask its server + /// for them. Set only by `findMediaServerResources(at:)`; `false` everywhere else, which + /// is the safe default — it costs a book its chapters, never a wrong write. + public var needsChapters = false + + public init( + id: Int = 0, + providerName: String, + providerId: String, + syncStatus: String, + lastSyncedAt: Date?, + hostId: String? = nil, + libraryItemUuid: String? = nil, + libraryItemName: String? = nil, + libraryItem: SimpleLibraryItem? = nil, + chapters: [ChapterMetadata] = [], + needsChapters: Bool = false + ) { + self.id = id + self.providerName = providerName + self.providerId = providerId + self.syncStatus = syncStatus + self.lastSyncedAt = lastSyncedAt + self.hostId = hostId + self.libraryItemUuid = libraryItemUuid + self.libraryItemName = libraryItemName + self.libraryItem = libraryItem + self.chapters = chapters + self.needsChapters = needsChapters + } +} + +extension SimpleExternalResource { + /// The server this link points at, or nil if it points at something else. The answer lives + /// on `ExternalResource.ProviderName.mediaServer`, the one exhaustive switch. Hardcover is + /// not a server, and a providerName this build doesn't know is not streamable either: + /// building a URL for it would need provider-specific knowledge we don't have. + public var mediaServer: ExternalResource.MediaServerProvider? { + ExternalResource.ProviderName(rawValue: providerName)?.mediaServer + } + + /// Convenience over `mediaServer` where only the yes/no matters. + public var isMediaServer: Bool { + mediaServer != nil + } +} + +extension Array where Element == SimpleExternalResource { + /// The item's media-server links, in no particular order. + public var mediaServerResources: [SimpleExternalResource] { + filter(\.isMediaServer) + } + + /// The item's media-server links in a stable order, for anything the user sees or hears. + /// + /// `mediaServerResources` filters an UNORDERED set (`resourcesArray` is `allObjects`), so an + /// item linked to two providers would otherwise draw its glyphs — and speak their names — in + /// whichever order the set yields, varying between launches. Same hazard `streamingResource` + /// pins, same comparator. + public var displayOrderedMediaServerResources: [SimpleExternalResource] { + mediaServerResources + .sorted { ($0.providerName, $0.providerId) < ($1.providerName, $1.providerId) } + } + + /// The link that decides streaming for the item, or nil if none can. + /// + /// Deterministic: the source set is UNORDERED, so an item linked to two streaming providers + /// would otherwise resolve to whichever the set yields first, varying between launches. + /// Stable (providerName, providerId) order pins it. + public var streamingResource: SimpleExternalResource? { + mediaServerResources + .sorted { ($0.providerName, $0.providerId) < ($1.providerName, $1.providerId) } + .first(where: { $0.syncStatus != ExternalResource.SyncStatus.notSynced.rawValue }) + } +} + +extension SimpleExternalResource { + public init(from item: ExternalResource, ignoreLibraryItem: Bool = false) { + self.id = Int(item.id) + self.providerName = item.providerName + self.providerId = item.providerId + self.syncStatus = item.syncStatus + self.lastSyncedAt = item.lastSyncedAt + self.processedFile = item.processedFile + self.hostId = item.hostId + self.libraryItemUuid = item.libraryItem?.uuid + self.libraryItemName = item.libraryItem?.title + self.libraryItem = (!ignoreLibraryItem && item.libraryItem != nil) ? SimpleLibraryItem(from: item.libraryItem!) : nil + } +} + + +/// A staged virtual-import selection traveling AS A VALUE from the integration +/// screen that produced it to its confirmation sheet (`.sheet(item:)`), and on +/// confirm to the import bus. Value semantics keep staging owned by whoever is +/// editing it — there is no shared mutable mailbox to fall out of sync with. +public struct ExternalImportBatch: Identifiable { + public let id = UUID() + public var resources: [SimpleExternalResource] + + public init(resources: [SimpleExternalResource]) { + self.resources = resources + } +} diff --git a/Shared/CoreData/Lightweight-Models/SimpleHardcoverBook.swift b/Shared/CoreData/Lightweight-Models/SimpleHardcoverBook.swift index c528eccc7..2e799ccc7 100644 --- a/Shared/CoreData/Lightweight-Models/SimpleHardcoverBook.swift +++ b/Shared/CoreData/Lightweight-Models/SimpleHardcoverBook.swift @@ -32,3 +32,21 @@ public struct SimpleHardcoverBook { self.userBookID = userBookID } } + +extension SimpleHardcoverBook { + /// Metadata repair for a status-only stub (created when a synced-down Hardcover link + /// crosses the reading threshold before this device ever fetched the book): metadata + /// comes from the FETCHED copy, monotonic state (status, userBookID) stays LOCAL — a + /// metadata repair must never regress reading progress already pushed to Hardcover, + /// and losing userBookID would break the unlink flow's removal call. + public func repairingMetadata(from fetched: SimpleHardcoverBook) -> SimpleHardcoverBook { + SimpleHardcoverBook( + id: fetched.id, + artworkURL: fetched.artworkURL, + title: fetched.title, + author: fetched.author, + status: status, + userBookID: userBookID + ) + } +} diff --git a/Shared/CoreData/Lightweight-Models/SimpleLibraryItem.swift b/Shared/CoreData/Lightweight-Models/SimpleLibraryItem.swift index 01c3e104d..f986bf6a0 100644 --- a/Shared/CoreData/Lightweight-Models/SimpleLibraryItem.swift +++ b/Shared/CoreData/Lightweight-Models/SimpleLibraryItem.swift @@ -29,6 +29,11 @@ public struct SimpleLibraryItem: Identifiable, Hashable, Equatable { public let lastPlayDate: Date? public let type: SimpleItemType public let uuid: String + /// Mutually recursive with SimpleExternalResource.libraryItem. The synthesized + /// Equatable/Hashable only terminate because back-pointers are built with + /// ignoreLibraryItem (resources held HERE carry libraryItem == nil) — preserve that + /// invariant when constructing snapshots. + public let externalResources: [SimpleExternalResource]? public var progress: Double { if type == .folder, @@ -52,6 +57,12 @@ public struct SimpleLibraryItem: Identifiable, Hashable, Equatable { && lhs.type.rawValue == rhs.type.rawValue && lhs.orderRank == rhs.orderRank && lhs.uuid == rhs.uuid + // Rows render provider icons from this — snapshots differing only in their + // external resources must not compare equal, or removeDuplicates/onChange + // paths skip refreshing a row that just gained or lost a media-server link + // As Sets: the source is an unordered NSSet, so array comparison made two + // snapshots of the same multi-resource item compare unequal on element order + && Set(lhs.externalResources ?? []) == Set(rhs.externalResources ?? []) } static var fetchRequestProperties = [ @@ -70,7 +81,7 @@ public struct SimpleLibraryItem: Identifiable, Hashable, Equatable { "originalFileName", "lastPlayDate", "type", - "uuid", + "uuid" ] public func hash(into hasher: inout Hasher) { @@ -97,7 +108,8 @@ public struct SimpleLibraryItem: Identifiable, Hashable, Equatable { originalFileName: String, lastPlayDate: Date?, type: SimpleItemType, - uuid: String + uuid: String, + externalResources: [SimpleExternalResource]? = nil ) { self.title = title self.details = details @@ -116,6 +128,7 @@ public struct SimpleLibraryItem: Identifiable, Hashable, Equatable { self.lastPlayDate = lastPlayDate self.type = type self.uuid = uuid + self.externalResources = externalResources } } @@ -138,6 +151,8 @@ extension SimpleLibraryItem { self.lastPlayDate = item.lastPlayDate self.uuid = item.uuid + self.externalResources = item.resourcesArray.map({ SimpleExternalResource(from: $0, ignoreLibraryItem: true) }) + switch item.type { case .folder: self.type = .folder diff --git a/Shared/CoreData/Migrations/DBVersion.swift b/Shared/CoreData/Migrations/DBVersion.swift index 31e0e982d..967ccb54a 100644 --- a/Shared/CoreData/Migrations/DBVersion.swift +++ b/Shared/CoreData/Migrations/DBVersion.swift @@ -10,7 +10,7 @@ import CoreData import Foundation enum DBVersion: CaseIterable { - case v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11 + case v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12 func model() -> NSManagedObjectModel { let modelURLs = Bundle.main @@ -40,6 +40,8 @@ enum DBVersion: CaseIterable { modelName = "Audiobook Player 10" case .v11: modelName = "Audiobook Player 11" + case .v12: + modelName = "Audiobook Player 12" } let model = modelURLs @@ -65,6 +67,8 @@ enum DBVersion: CaseIterable { return "MappingModel_v9_to_v10" case .v11: return "MappingModel_v10_to_v11" + case .v12: + return "MappingModel_v11_to_v12" default: return nil } diff --git a/Shared/CoreData/Migrations/MappingModel_v11_to_v12.xcmappingmodel/xcmapping.xml b/Shared/CoreData/Migrations/MappingModel_v11_to_v12.xcmappingmodel/xcmapping.xml new file mode 100644 index 000000000..87e7f2b00 --- /dev/null +++ b/Shared/CoreData/Migrations/MappingModel_v11_to_v12.xcmappingmodel/xcmapping.xml @@ -0,0 +1,607 @@ + + + + + + 134481920 + 835EC16E-B886-4D97-B8EF-982707BFB323 + 217 + + + + NSPersistenceFrameworkVersion + 1526 + NSPersistenceMaximumFrameworkVersion + 1526 + NSStoreModelVersionChecksumKey + bMpud663vz0bXQE24C6Rh4MvJ5jVnzsD2sI3njZkKbc= + NSStoreModelVersionHashes + + XDDevAttributeMapping + + 0plcXXRN7XHKl5CcF+fwriFmUpON3ZtcI/AfK748aWc= + + XDDevEntityMapping + + qeN1Ym3TkWN1G6dU9RfX6Kd2ccEvcDVWHpd3LpLgboI= + + XDDevMappingModel + + EqtMzvRnVZWkXwBHu4VeVGy8UyoOe+bi67KC79kphlQ= + + XDDevPropertyMapping + + XN33V44TTGY4JETlMoOB5yyTKxB+u4slvDIinv0rtGA= + + XDDevRelationshipMapping + + akYY9LhehVA/mCb4ATLWuI9XGLcjpm14wWL1oEBtIcs= + + + NSStoreModelVersionHashesDigest + +Hmc2uYZK6og+Pvx5GUJ7oW75UG4V/ksQanTjfTKUnxyGWJRMtB5tIRgVwGsrd7lz/QR57++wbvWsr6nxwyS0A== + NSStoreModelVersionHashesVersion + 3 + NSStoreModelVersionIdentifiers + + + + + + + + + 1 + folder + + + + artworkURL + + + + uuid + + + + relativePath + + + + lastPlayDate + + + + darkSecondarySystemFillHex + + + + currentTime + + + + darkSecondarySystemBackgroundHex + + + + status + + + + lightQuaternarySystemFillHex + + + + lightSystemBackgroundHex + + + + lightSecondarySystemFillHex + + + + lightTertiarySystemBackgroundHex + + + + lightPrimaryHex + + + + id + + + + lightSystemFillHex + + + + darkSystemGroupedBackgroundHex + + + + email + + + + duration + + + + donationMade + + + + 1 + selected + + + + 1 + libraryItem + + + + PlaybackRecord + Undefined + 1 + PlaybackRecord + 1 + + + + + + duration + + + + Folder + Undefined + 7 + Folder + 1 + + + + + + duration + + + + details + + + + Library + Undefined + 8 + Library + 1 + + + + + + HardcoverBook + Undefined + 3 + HardcoverBook + 1 + + + + + + Undefined + 9 + ExternalResource + 1 + + + + + + lastPlayDate + + + + 1 + item + + + + artworkURL + + + + percentCompleted + + + + date + + + + darkSecondaryHex + + + + artworkURL + + + + darkQuaternarySystemFillHex + + + + 1 + library + + + + start + + + + title + + + + 1 + lastPlayedItem + + + + author + + + + hostId + + + + type + + + + 1 + extractedThemes + + + + 1 + externalResources + + + + processedFile + + + + 1 + bookmarks + + + + 1 + currentTheme + + + + 1 + items + + + + title + + + + orderRank + + + + note + + + + time + + + + 1 + hardcoverBook + + + + 1 + book + + + + 1 + lastPlayed + + + + relativePath + + + + title + + + + originalFileName + + + + 1 + chapters + + + + uuid + + + + 1 + hardcoverBook + + + + 1 + libraryItem + + + + remoteURL + + + + darkSeparatorHex + + + + details + + + + darkTertiarySystemFillHex + + + + darkSystemBackgroundHex + + + + lightSecondarySystemBackgroundHex + + + + lightSeparatorHex + + + + lightSecondaryHex + + + + title + + + + lightAccentHex + + + + lightSystemGroupedBackgroundHex + + + + darkAccentHex + + + + providerId + + + + lightTertiarySystemFillHex + + + + userBookID + + + + darkPrimaryHex + + + + time + + + + hasSubscription + + + + Shared/CoreData/BookPlayer.xcdatamodeld/Audiobook Player 11.xcdatamodel + YnBsaXN0MDDUAAEAAgADAAQABQAGAAcAClgkdmVyc2lvblkkYXJjaGl2ZXJUJHRvcFgkb2JqZWN0 +cxIAAYagXxAPTlNLZXllZEFyY2hpdmVy0QAIAAlUcm9vdIABrxEIMwALAAwAGQA1ADYANwBRAFIAUwBUAFUAVgBXAFgAWQBaAFsAdgB3AHgAfgB/AIsAoQCiAKMApAClAKYApwCoAKkAqgDDAMYAzQDTAOIA4wDyAPUBBAETARYAdQEmATUBOQE9AUwBUgFTAVsBagFzAXkBegGPAZABmAGZAZoBpgG6AbsBvAG9Ab4BvwHAAcEBwgHRAeAB7wHzAgICEQISAiECMAIxAkACTAJeAl8CYAJhAmICYwJkAmUALwJ0AoMChAKTAqICxALFAssC1wLtAvwC/wMOAx0DIAMvAz4DQQNQA18DYwNyA4EDggOuA68DsAOxA7IDswO0A7UDtgO3A7gDuQO6A7sDvAO9A74DvwPAA8EDwgPXA9gD4APsBAAEDwQeBC0EMQRABE8EXgRtBHwEiASaBKkEuATHBNYE5QT0BQMFCwUgBSEFKQUqBTYFSgVZBWgFdwV7BYoFmQWoBbcFxgXSBeQF5QXmBecF6AXpBeoF6wX6BgkGGAYnBigGNwZGBlUGXQZyBnMGewaHBpsGqga5BsgGzAbbBuoG+QcIBxcHIwc1B0QHUwdUB2MHcgeMB40HkwefB7UHxAfHB9YH5QfoB/cIBggJCBgIJwgrCDoISQhKCFwIXQheCF8IYAhhCGIIdwh4CIAIjAigCK8IvgjNCNEI4AjvCP4JDQkcCSgJOglJCUoJWQloCXcJeAmHCZYJpQm6CbsJwwnPCeMJ8goBChAKFAojCjIKQQpQCl8Kawp9CowKmwqqCrkKyArXCuYK+wr8CwQLEAskCzMLQgtRC1ULZAtzC4ILkQugC6wLvgvNC9wL6wv6C/sMCgwZDCgMPQw+DEYMUgxmDHUMhAyTDJcMpgy1DMQM0wziDO4NAA0PDR4NLQ08DUsNWg1pDX4Nfw2HDZMNpw22DcUN1A3YDecN9g4FDhQOIw4vDkEOUA5fDm4OfQ6MDpsOqg6/DsAOyA7UDugO9w8GDxUPGQ8oDzcPRg9VD2QPcA+CD5EPoA+vD74Pvw/OD90P7BABEAIQChAWECoQORBIEFcQWxBqEHkQiBCXEKYQshDEENMQ4hDxEQARDxEeES0RLhExEToRSRFYEWcRfBF9EYURkRGlEbQRwxHSEdYR5RH0EgMSEhIhEi0SPxJOEk8SXhJtEnwSfRKMEpsSqhK/EsASyBLUEugS9xMGExUTGRMoEzcTRhNVE2QTcBOCE5EToBOvE74TzRPcE+sUABQBFAkUFRQpFDgURxRWFFoUaRR4FIcUlhSlFLEUwxTSFOEU8BT/FQ4VHRUsFUEVQhVKFVYVahV5FYgVlxWbFaoVuRXIFdcV5hXyFgQWExYUFiMWMhZBFkIWURZgFm8WhBaFFo0WmRatFrwWyxbaFt4W7Rb8FwsXGhcpFzUXRxdWF2UXdBeDF5IXoRewF8UXxhfOF9oX7hf9GAwYGxgfGC4YPRhMGFsYahh2GIgYlximGLUYxBjTGOIY8RkGGQcZDxkbGS8ZPhlNGVwZYBlvGX4ZjRmcGasZtxnJGdgZ5xn2GgUaBhoVGiQaMxpIGkkaURpdGnEagBqPGp4aohqxGsAazxreGu0a+RsLGxobKRs4G0cbYRtiG2gbdBuKG5kbnBurG7obvRvMG9sb3hvtG/wcABwPHB4cKhwrHCwcLRxCHEMcSxxXHGscehyJHJgcnByrHLocyRzYHOcc8x0FHRQdIx0yHUEdUB1fHW4dgx2EHYwdmB2sHbsdyh3ZHd0d7B37HgoeGR4oHjQeRh5VHmQecx6CHpEeoB6vHsQexR7NHtke7R78HwsfGh8eHy0fPB9LH1ofaR91H4cflh+lH7Qfwx/SH+Ef8CAFIAYgDiAaIC4gPSBMIFsgXyBuIH0gjCCbIKogtiDIINcg5iD1IQQhHiEfISUhMSFHIVYhWSFoIWkheCF7IYohmSGcIashuiG+Ic0h3CIWIhciGCIZIhoiGyIcIh0iHiIfIiAiISIiIiMiJCIlIiYiJyIoIikiKiIrIiwiLSIuIi8iRCJFIk0iWSJtInwiiyKaIp4irSK8Issi2iLbIuoi9iMIIxcjJiM1I0QjUyNiI3EjhiOHI48jmyOvI74jzSPcI+Aj7yP+JA0kHCQrJDckSSRYJGckdiSFJJQkoySyJMckyCTQJNwk8CT/JQ4lHSUhJTAlPyVOJV0lbCV4JYolmSWoJbclxiXVJeQl8yYIJgkmESYdJjEmQCZPJl4mYiZxJoAmjyaeJp8mria6Jswm2ybqJvknCCcXJyYnNSdKJ0snUydfJ3MngieRJ6AnpCezJ8In0SfgJ+8n+ygNKBwoKyg6KEkoWChnKHYoiyiMKJQooCi0KMMo0ij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UWJVYqlirWLNYv1jTWOJY8VkAWQRZE1kiWTFZQFlPWVtZbVl8WYtZmlmpWbhZx1nWWetZ7Fn0WgBaFFojWjJaQVpFWlRaY1pyWoFakFqcWq5avVrMWtta6lr5WwhbF1ssWy1bNVtBW1VbZFtzW4JbhluVW6Rbs1vCW9Fb3VvvW/5cDVwcXCtcOlxJXFhcbVxuXHZcglyWXKVctFzDXMdc1lzlXPRdA10SXR5dMF0/XU5dXV1sXXtdil2ZXa5dr123XcNd113mXfVeBF4IXhdeJl41XkReU15fXnFegF6PXp5erV68Xste2l7dXuxe+18KXw5fD18VXxxfHV8sXztfSl9OX2hfaV9vX3tfkV+gX6Nfsl/BX8Rf01/iX+Vf9GADYAdgFmAlYDFgMmAzYDRgSWBKYFJgXmByYIFgkGCfYKNgsmDBYNBg32DuYPphDGEbYSphOWFIYVdhZmF1YYphi2GTYZ9hs2HCYdFh4GHkYfNiAmIRYiBiL2I7Yk1iXGJrYnpiiWKYYqditmLLYsxi1GLgYvRjA2MSYyFjJWM0Y0NjUmNhY3BjfGOOY51jrGO7Y8pj2WPoY/dkDGQNZBVkIWQ1ZERkU2RiZGZkdWSEZJNkomSxZL1kz2TeZO1k/GULZRplKWU4ZTtlVWVWZVxlaGV+ZY1lkGWfZa5lsWXAZc9l0mXhZfBl9GYDZhJmE2YbZhxmMWYyZjpmRmZaZmlmeGaHZotmmmapZrhmx2bWZuJm9GcDZxJnIWcwZz9nTmddZ3Jnc2d7Z4dnm2eqZ7lnyGfMZ9tn6mf5aAhoF2gjaDVoRGhTaGJocWiAaI9onmihaKVoqWitaLVouGi8aL1VJG51bGzWAA0ADgAPABAAEQASABMAFAAVABYAFwAYXxAPX3hkX3Jvb3RQYWNrYWdlViRjbGFzc11feGRfbW9kZWxOYW1lXF94ZF9jb21tZW50c18QFV9jb25maWd1cmF0aW9uc0J5TmFtZV8QF19tb2RlbFZlcnNpb25JZGVudGlmaWVygAKBCDKAAIEIL4EIMIEIMd4AGgAbABwAHQAeAB8AIAAOACEAIgAjACQAJQAmACcAKAApAAkAJwAVAC0ALgAvADAAMQAnACcAFV8QHFhEQnVja2V0Rm9yQ2xhc3Nlc3dhc0VuY29kZWRfEBpYREJ1Y2tldEZvclBhY2thZ2Vzc3RvcmFnZV8QHFhEQnVja2V0Rm9ySW50ZXJmYWNlc3N0b3JhZ2VfEA9feGRfb3duaW5nTW9kZWxfEB1YREJ1Y2tldEZvclBhY2thZ2Vzd2FzRW5jb2RlZFZfb3duZXJfEBtYREJ1Y2tldEZvckRhdGFUeXBlc3N0b3JhZ2VbX3Zpc2liaWxpdHlfEBlYREJ1Y2tldEZvckNsYXNzZXNzdG9yYWdlVV9uYW1lXxAfWERCdWNrZXRGb3JJbnRlcmZhY2Vzd2FzRW5jb2RlZF8QHlhEQnVja2V0Rm9yRGF0YVR5cGVzd2FzRW5jb2RlZF8QEF91bmlxdWVFbGVtZW50SUSABIEILYEIK4ABgASAAIEILIEILhAAgAWAA4AEgASAAFBTWUVT0wA4ADkADgA6AEUAUFdOUy5rZXlzWk5TLm9iamVjdHOqADsAPAA9AD4APwBAAEEAQgBDAESABoAHgAiACYAKgAuADIANgA6AD6oARgBHAEgASQBKAEsATABNAE4AT4AQgNeBBraBBg+BBt6AYYEHdIEH54ECVoECxYAvVkZvbGRlcl1IYXJkY292ZXJCb29rVEJvb2tYQm9va21hcmtXQ2hhcHRlcltMaWJyYXJ5SXRlbVdBY2NvdW50XlBsYXliYWNrUmVjb3JkV0xpYnJhcnlVVGhlbWXfEBAAXABdAF4AXwAfAGAAYQAhAGIAYwAOACMAZABlACYAZgBnAGgAJwAnABMAbABtAC8AJwBnAHAAOwBnAHMAdAB1XxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QJFhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zZHVwbGljYXRlc18QJFhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkXxAhWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNvcmRlcmVkXxAhWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNzdG9yYWdlW19pc0Fic3RyYWN0gBKANoAEgASAAoATgQGWgASAEoEBmIAGgBKBB3OAEQgSMJqvzVdvcmRlcmVk0wA4ADkADgB5AHsAUKEAeoAUoQB8gBWAL15YRF9QU3RlcmVvdHlwZdkAHwAjAIAADgAmAIEAIQBmAIIARgB6AGcAhgAVACcALwB1AIpfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAEIAUgBKANYAAgAQIgBbTADgAOQAOAIwAlgBQqQCNAI4AjwCQAJEAkgCTAJQAlYAXgBiAGYAagBuAHIAdgB6AH6kAlwCYAJkAmgCbAJwAnQCeAJ+AIIAkgCaAKIApgCuALYAwgDSAL18QE1hEUE1Db21wb3VuZEluZGV4ZXNfEBBYRF9QU0tfZWxlbWVudElEXxAZWERQTVVuaXF1ZW5lc3NDb25zdHJhaW50c18QGlhEX1BTS192ZXJzaW9uSGFzaE1vZGlmaWVyXxAZWERfUFNLX2ZldGNoUmVxdWVzdHNBcnJheV8QEVhEX1BTS19pc0Fic3RyYWN0XxAPWERfUFNLX3VzZXJJbmZvXxATWERfUFNLX2NsYXNzTWFwcGluZ18QFlhEX1BTS19lbnRpdHlDbGFzc05hbWXfEA8AqwCsAK0AHwCuAK8AsAAhALEADgAjALIAswAmALQAFQC2ABUAfAB1AHUAdQAvAHUAvQCNAHUAdQAVAHVVX3R5cGVYX2RlZmF1bHRcX2Fzc29jaWF0aW9uW19pc1JlYWRPbmx5WV9pc1N0YXRpY1lfaXNVbmlxdWVaX2lzRGVyaXZlZFpfaXNPcmRlcmVkXF9pc0NvbXBvc2l0ZVdfaXNMZWFmgACAIYAAgBUICAgIgCOAFwgIgAAI0gA5AA4AxADFoIAi0gDHAMgAyQDKWiRjbGFzc25hbWVYJGNsYXNzZXNeTlNNdXRhYmxlQXJyYXmjAMkAywDMV05TQXJyYXlYTlNPYmplY3TSAMcAyADOAM9fEBBYRFVNTFByb3BlcnR5SW1wpADQANEA0gDMXxAQWERVTUxQcm9wZXJ0eUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w3xAPAKsArACtAB8ArgCvALAAIQCxAA4AIwCyALMAJgC0ABUA1QAVAHwAdQB1AHUALwB1AL0AjgB1AHUAFQB1gACAJYAAgBUICAgIgCOAGAgIgAAIWlBsYXlsaXN0VjPfEA8AqwCsAK0AHwCuAK8AsAAhALEADgAjALIAswAmALQAFQDlABUAfAB1AHUAdQAvAHUAvQCPAHUAdQAVAHWAAIAngACAFQgICAiAI4AZCAiAAAjSADkADgDzAMWggCLfEA8AqwCsAK0AHwCuAK8AsAAhALEADgAjALIAswAmALQAFQAVABUAfAB1AHUAdQAvAHUAvQCQAHUAdQAVAHWAAIAAgACAFQgICAiAI4AaCAiAAAjfEA8AqwCsAK0AHwCuAK8AsAAhALEADgAjALIAswAmALQAFQEGABUAfAB1AHUAdQAvAHUAvQCRAHUAdQAVAHWAAIAqgACAFQgICAiAI4AbCAiAAAjSADkADgEUAMWggCLfEA8AqwCsAK0AHwCuAK8AsAAhALEADgAjALIAswAmALQAFQEYABUAfAB1AHUAdQAvAHUAvQCSAHUAdQAVAHWAAIAsgACAFQgICAiAI4AcCAiAAAgI3xAPAKsArACtAB8ArgCvALAAIQCxAA4AIwCyALMAJgC0ABUBKAAVAHwAdQB1AHUALwB1AL0AkwB1AHUAFQB1gACALoAAgBUICAgIgCOAHQgIgAAI0wA4ADkADgE2ATcAUKCggC/SAMcAyAE6ATtfEBNOU011dGFibGVEaWN0aW9uYXJ5owE6ATwAzFxOU0RpY3Rpb25hcnnfEA8AqwCsAK0AHwCuAK8AsAAhALEADgAjALIAswAmALQAFQE/ABUAfAB1AHUAdQAvAHUAvQCUAHUAdQAVAHWAAIAxgACAFQgICAiAI4AeCAiAAAjWACMADgAmAGYAHwAhAU0BTgAVAHUAFQAvgDKAM4AACIAAXxAUWERHZW5lcmljUmVjb3JkQ2xhc3PSAMcAyAFUAVVdWERVTUxDbGFzc0ltcKYBVgFXAVgBWQFaAMxdWERVTUxDbGFzc0ltcF8QElhEVU1MQ2xhc3NpZmllckltcF8QEVhEVU1MTmFtZXNwYWNlSW1wXxAUWERVTUxOYW1lZEVsZW1lbnRJbXBfEA9YRFVNTEVsZW1lbnRJbXDfEA8AqwCsAK0AHwCuAK8AsAAhALEADgAjALIAswAmALQAFQA7ABUAfAB1AHUAdQAvAHUAvQCVAHUAdQAVAHWAAIAGgACAFQgICAiAI4AfCAiAAAjSAMcAyAFrAWxfEBJYRFVNTFN0ZXJlb3R5cGVJbXCnAW0BbgFvAXABcQFyAMxfEBJYRFVNTFN0ZXJlb3R5cGVJbXBdWERVTUxDbGFzc0ltcF8QElhEVU1MQ2xhc3NpZmllckltcF8QEVhEVU1MTmFtZXNwYWNlSW1wXxAUWERVTUxOYW1lZEVsZW1lbnRJbXBfEA9YRFVNTEVsZW1lbnRJbXDTADgAOQAOAXQBdgBQoQF1gDehAXeAOIAvVWl0ZW1z3xASAKsArACtAXsAHwCvALABfAAhAK4BfQCxAA4AIwCyALMAJgC0ABUAFQAVACcARgB1AHUBhQAvAHUAZwB1AYkBdQB1AHUBjQB1XxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASAEAgIgDoIgBIIgKCANwgIgDkIEoZDEEPTADgAOQAOAZEBlABQogGSAZOAO4A8ogGVAZaAPYBSgC9fEBJYRF9QUHJvcFN0ZXJlb3R5cGVfEBBYRF9QUl9TdGVyZW90eXBl2QAfACMBmwAOACYBnAAhAGYBnQF3AZIAZwCGABUAJwAvAHUBpV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYA4gDuAEoA1gACABAiAPtMAOAA5AA4BpwGwAFCoAagBqQGqAasBrAGtAa4Br4A/gECAQYBCgEOARIBFgEaoAbEBsgGzAbQBtQG2AbcBuIBHgEiASYBLgEyAToBPgFGAL18QG1hEX1BQU0tfaXNTdG9yZWRJblRydXRoRmlsZV8QG1hEX1BQU0tfdmVyc2lvbkhhc2hNb2RpZmllcl8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eZGFya1N5c3RlbUdyb3VwZWRCYWNrZ3JvdW5kSGV4XxAgZGFya1NlY29uZGFyeVN5c3RlbUJhY2tncm91bmRIZXhfEBhsaWdodFN5c3RlbUJhY2tncm91bmRIZXhfEBBkYXJrU2VwYXJhdG9ySGV4XxARZGFya1N5c3RlbUZpbGxIZXhfEBpsaWdodFRlcnRpYXJ5U3lzdGVtRmlsbEhleF5saWdodEFjY2VudEhleF8QEWxpZ2h0U2VwYXJhdG9ySGV4XxASbGlnaHRTeXN0ZW1GaWxsSGV4XxAPbGlnaHRQcmltYXJ5SGV4XmRhcmtQcmltYXJ5SGV4XxAXZGFya1N5c3RlbUJhY2tncm91bmRIZXhfEBlkYXJrVGVydGlhcnlTeXN0ZW1GaWxsSGV4XxAbbGlnaHRTZWNvbmRhcnlTeXN0ZW1GaWxsSGV4WHNlbGVjdGVkXWRhcmtBY2NlbnRIZXhfECBsaWdodFRlcnRpYXJ5U3lzdGVtQmFja2dyb3VuZEhleF8QH2xpZ2h0U3lzdGVtR3JvdXBlZEJhY2tncm91bmRIZXjfEBIAqwCsAK0iMAAfAK8AsCIxACEAriIyALEADgAjALIAswAmALQAFQAVABUAJwBPAHUAdSI6AC8AdQBnAHUFGiHeAHUAdSJCAHVfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWSAAIAAgACABIECxQgIgQL0CIASCIDBgQLZCAiBAvMIEpHxUPXTADgAOQAOIkYiSQBQogGSBSSAO4CkoiJKIkuBAvWBAwGAL9kAHwAjIk4ADgAmIk8AIQBmIlAh+gGSAGcAhgAVACcALwB1IlhfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBAvKAO4ASgDWAAIAECIEC9tMAOAA5AA4iWiJjAFCoAagBqQGqAasBrAGtAa4Br4A/gECAQYBCgEOARIBFgEaoImQiZSJmImciaCJpImoia4E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eAG0ALwAnAGcAcAA+AGdNJU0mAHVfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2VfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNkdXBsaWNhdGVzXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3N0b3JhZ2WAEoEGIYAEgASAAoEGEYEBloAEgBKBAZiACYASgQaBgQYQCBKh62BI0wA4ADkADk0qTSwAUKEAeoAUoU0tgQYSgC/ZAB8AI00wAA4AJk0xACEAZk0yAEkAegBnAIYAFQAnAC8AdU06XxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgQYPgBSAEoA1gACABAiBBhPTADgAOQAOTTxNRgBQqQCNAI4AjwCQAJEAkgCTAJQAlYAXgBiAGYAagBuAHIAdgB6AH6lNR01ITUlNSk1LTUxNTU1OTU+BBhSBBhaBBheBBhmBBhqBBhyBBh2BBh+BBiCAL98QDwCrAKwArQAfAK4ArwCwACEAsQAOACMAsgCzACYAtAAVTVMAFU0tAHUAdQB1AC8AdQC9AI0AdQB1ABUAdYAAgQYVgACBBhIICAgIgCOAFwgIgAAI0gA5AA5NYQDFoIAi3xAPAKsArACtAB8ArgCvALAAIQCxAA4AIwCyALMAJgC0ABUAFQAVTS0AdQB1AHUALwB1AL0AjgB1AHUAFQB1gACAAIAAgQYSCAgICIAjgBgICIAACN8QDwCrAKw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lbnRJbXBfEA9YRFVNTEVsZW1lbnRJbXDSADkADmi2AMWggCLTADgAOQAOaLlougBQoKCAL1DSAMcAyGi+aL9ZWERQTU1vZGVso2i+aMAAzFdYRE1vZGVsAAAACAAAABkAAAAiAAAALAAAADEAAAA6AAAAPwAAAFEAAABWAAAAWwAAAF0AABDHAAAQzQAAEOYAABD4AAAQ/wAAEQ0AABEaAAARMgAAEUwAABFOAAARUQAAEVMAABFWAAARWQAAEVwAABGVAAARtAAAEdEAABHwAAASAgAAEiIAABIpAAASRwAAElMAABJvAAASdQAAEpcAABK4AAASywAAEs0AABLQAAAS0wAAEtUAABLXAAAS2QAAEtwAABLfAAAS4QAAEuMAABLlAAAS5wAAEukAABLrAAAS7AAAEvAAABL9AAATBQAAExAAABMlAAATJwAAEykAABMrAAATLQAAEy8AABMxAAATMwAAEzUAABM3AAATOQAAE04AABNQAAATUgAAE1UAABNYAAATWwAAE10AABNgAAATYwAAE2YAABNpAAATawAAE3IAABOAAAAThQAAE44AABOWAAATogAAE6oAABO5AAATwQAAE8cAABQKAAAULgAAFFIAABR1AAAUnAAAFLwAABTjAAAVCgAAFSoAABVOAAAVcgAAFX4AABWAAAAVggAAFYQAABWGAAAViAAAFYoAABWNAAAVjwAAFZEAABWUAAAVlgAAFZgAABWbAAAVnQAAFZ4AABWjAAAVqwAAFbgAABW7AAAVvQAAFcAAABXCAAAVxAAAFdMAABX4AAAWHAAAFkMAABZnAAAWaQAAFmsAABZtAAAWbwAAFnEAABZzAAAWdAAAFnYAABaDAAAWlgAAFpgAABaaAAAWnAAAFp4AABagAAAWogAAFqQAABamAAAWqAAAFrsAABa9AAAWvwAAFsEAABbDAAAWxQAAFscAABbJAAAWywAAFs0AABbPAAAW5QAAFvgAABcUAAAXMQAAF00AABdhAAAXcwAAF4kAABeiAAAX4QAAF+cAABfwAAAX/QAAGAkAABgTAAAYHQAAGCgAABgzAAAYQAAAGEgAABhKAAAYTAAAGE4AABhQAAAYUQAAGFIAABhTAAAYVAAAGFYAABhYAAAYWQAAGFoAABhcAAAYXQAAGGYAABhnAAAYaQAAGHIAABh9AAAYhgAAGJU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AACWOAAAlkAAAJZIAACWTAAAllAAAJZYAACWXAAAl1gAAJdgAACXaAAAl3AAAJd4AACXfAAAl4AAAJeEAACXiAAAl5AAAJeYAACXnAAAl6AAAJeoAACXrAAAmPgAAJmIAACaGAAAmpgAAJs0AACb0AAAnFAAAJzsAACdfAAAnggAAJ6kAACfNAAAn8QAAKBUAACg8AAAoPQAAKD8AAChBAAAoQwAAKEUAAChIAAAoSgAAKEwAAChPAAAoUQAAKFMAAChWAAAoWAAAKFoAAChcAAAoXgAAKGAAAChjAAAoZgAAKGsAACh4AAAoewAAKH0AACiAAAAoggAAKIQAACipAAAozQAAKPQAACkYAAApGgAAKRwAACkeAAApIAAAKSIAACkkAAApJQAAKScAACk0AAApRwAAKUkAAClLAAApTQAAKU8AAClRAAApUwAAKVUAAClXAAApWQAAKWwAACluAAApcAAAKXIAACl0AAApdgAAKXgAACl6AAApfAAAKX4AACmAAAApvwAAKcEAACnDAAApxQAAKccAACnIAAApyQAAKcoAACnLAAApzQAAKc8AACnQAAAp0QAAKdMAACnUAAAp3QAAKd4AACngAAAqHwAAKiEAACojAAAqJQAAKicAACooAAAqKQAAKioAACorAAAqLQAAKi8AACowAAAqMQAAKjMAACo0AAAqcwAAKnUAACp3AAAqeQAAKnsAACp8AAAqfQAAKn4AACp/AAAqgQAAKoMAACqEAAAqhQAAKocAACqIAAAqkQAAKpIAACqUAAAq0wAAKtUAACrXAAAq2QAAKtsAACrcAAAq3QAAKt4AACrfAAAq4QAAKuMAACrkAAAq5QAAKucAACroAAArJwAAKykAACsrAAArLQAAKy8AACswAAArMQAAKzIAACszAAArNQAAKzcAACs4AAArOQAAKzsAACs8AAArRQAAK0YAACtIAAArhwAAK4kAACuLAAArjQAAK48AACuQAAArkQAAK5IAACuTAAArlQAAK5cAACuYAAArmQAAK5sAACucAAAr2wAAK90AACvfAAAr4QAAK+MAACvkAAAr5QAAK+YAACvnAAAr6QAAK+sAACvsAAAr7QAAK+8AACvwAAAr/QAAK/4AACv/AAAsAQAALEAAACxCAAAsRAAALEYAACxIAAAsSQAALEoAACxLAAAsTAAALE4AACxQAAAsUQAALFIAACxUAAAsVQAALJQAACyWAAAsmAAALJoAACycAAAsnQAALJ4AACyfAAAsoAAALKIAACykAAAspQAALKYAACyoAAAsqQAALLUAACzCAAAs7QAALO8AACzxAAAs8wAALPUAACz3AAAs+QAALPsAACz9AAAs/wAALQEAAC0DAAAtBQAALQcAAC0JAAAtCwAALQ0AAC0PAAAtEQAALRMAAC0VAAAtQAAALUIAAC1EAAAtRgAALUkAAC1MAAAtTwAALVIAAC1VAAAtWAAALVsAAC1eAAAtYQAALWQAAC1nAAAtagAALW0AAC1wAAAtcwAALXYAAC15AAAtewAALYIAAC2IAAAtlgAALZ8AAC2rAAAtvgAALcQAAC3OAAAt0wAALeAAAC3rAAAt9gAALfsAAC4DAAAuCwAALhUAAC4gAAAuKgAALj0AAC5KAAAulQAALrgAAC7YAAAu+AAALvoAAC78AAAu/gAALwAAAC8CAAAvAwAALwQAAC8GAAAvBwAALwkAAC8KAAAvDAAALw4AAC8PAAAvEAAALxIAAC8TAAAvGAAALyUAAC8qAAAvLAAALy4AAC8zAAAvNQAALzcAAC85AAAvXgAAL4IAAC+pAAAvzQAAL88AAC/RAAAv0wAAL9UAAC/XAAAv2QAAL9oAAC/cAAAv6QAAL/oAAC/8AAAv/gAAMAAAADACAAAwBAAAMAYAADAIAAAwCgAAMBsAADAdAAAwHwAAMCEAADAjAAAwJQAAMCcAADApAAAwKwAAMC0AADBsAAAwbgAAMHAAADByAAAwdAAAMHUAADB2AAAwdwAAMHgAADB6AAAwfAAAMH0AADB+AAAwgAAAMIEAADDAAAAwwgAAMMQAADDGAAAwyAAAMMkAADDKAAAwywAAMMwAADDOAAAw0AAAMNEAADDSAAAw1AAAMNUAADEUAAAxFgAAMRgAADEaAAAxHAAAMR0AADEeAAAxHwAAMSAAADEiAAAxJAAAMSUAADEmAAAxKAAAMSkAADE2AAAxNwAAMTgAADE6AAAxeQAAMXsAADF9AAAxfwAAMYEAADGCAAAxgwAAMYQAADGFAAAxhwAAMYkAADGKAAAxiwAAMY0AADGOAAAxzQAAMc8AADHRAAAx0wAAMdUAADHWAAAx1wAAMdgAADHZAAAx2wAAMd0AADHeAAAx3wAAMeEAADHiAAAyIQAAMiMAADIlAAAyJwAAMikAADIqAAAyKwAAMiwAADItAAAyLwAAMjEAADIyAAAyMwAAMjUAADI2AAAydQAAMncAADJ5AAAyewAAMn0AADJ+AAAyfwAAMoAAADKBAAAygwAAMoUAADKGAAAyhwAAMokAADKKAAAyyQAAMssAADLNAAAyzwAAMtEAADLSAAAy0wAAMtQAADLVAAAy1wAAMtkAADLaAAAy2wAAMt0AADLeAAAzAwAAMycAADNOAAAzcgAAM3QAADN2AAAzeAAAM3oAADN8AAAzfgAAM38AADOBAAAzjgAAM50AADOfAAAzoQAAM6MAADOlAAAzpwAAM6kAADOrAAAzugAAM7wAADO+AAAzwAAAM8IAADPEAAAzxgAAM8gAADPKAAA0CQAANAsAADQNAAA0DwAANBEAADQSAAA0EwAANBQAADQVAAA0FwAANBkAADQaAAA0GwAANB0AADQeAAA0XQAANF8AADRhAAA0YwAANGUAADRmAAA0ZwAANGgAADRpAAA0awAANG0AADRuAAA0bwAANHEAADRyAAA0sQAANLMAADS1AAA0twAANLkAADS6AAA0uwAANLwAADS9AAA0vwAANMEAADTCAAA0wwAANMUAADTGAAA1BQAANQcAADUJAAA1CwAANQ0AADUOAAA1DwAANRAAADURAAA1EwAANRUAADUWAAA1FwAANRkAADUaAAA1WQAANVsAADVdAAA1XwAANWEAADViAAA1YwAANWQAADVlAAA1ZwAANWkAADVqAAA1awAANW0AADVuAAA1rQAANa8AADWxAAA1swAANbUAADW2AAA1twAANbgAADW5AAA1uwAANb0AADW+AAA1vwAANcEAADXCAAA2AQAANgMAADYFAAA2BwAANgkAADYKAAA2CwAANgwAADYNAAA2DwAANhEAADYSAAA2EwAANhUAADYWAAA2HwAANjIAADY/AAA2UgAANl8AADZyAAA2iQAANpsAADbmAAA3CQAANykAADdJAAA3SwAAN00AADdPAAA3UQAAN1MAADdUAAA3VQAAN1cAADdYAAA3WgAAN1sAADddAAA3XwAAN2AAADdhAAA3YwAAN2QAADdpAAA3dgAAN3sAADd9AAA3fwAAN4QAADeGAAA3iAAAN4oAADefAAA3xAAAN+gAADgPAAA4MwAAODU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AAEO3AABDuAAAQ7oAAEO8AABDvQAAQ74AAEPAAABDwQAARAAAAEQCAABEBAAARAYAAEQIAABECQAARAoAAEQLAABEDAAARA4AAEQQAABEEQAARBIAAEQUAABEFQAARFQAAERWAABEWAAARFoAAERcAABEXQAARF4AAERfAABEYAAARGIAAERkAABEZQAARGYAAERoAABEaQAARI4AAESyAABE2QAARP0AAET/AABFAQAARQMAAEUFAABFBwAARQkAAEUKAABFDAAARRkAAEUoAABFKgAARSwAAEUuAABFMAAARTIAAEU0AABFNgAARUUAAEVHAABFSQAARUsAAEVNAABFUAAARVMAAEVWAABFWAAARZcAAEWZAABFmwAARZ0AAEWfAABFoAAARaEAAEWiAABFowAARaUAAEWnAABFqAAARakAAEWrAABFrAAAResAAEXtAABF7wAARfEAAEXzAABF9AAARfUAAEX2AABF9wAARfkAAEX7AABF/AAARf0AAEX/AABGAAAARgIAAEZBAABGQwAARkUAAEZHAABGSQAARkoAAEZLAABGTAAARk0AAEZPAABGUQAARlIAAEZTAABGVQAARlYAAEaVAABGlwAARpkAAEabAABGnQAARp4AAEafAABGoAAARqEAAEajAABGpQAARqYAAEanAABGqQAARqoAAEbtAABHEQAARzUAAEdYAABHfwAAR58AAEfGAABH7QAASA0AAEgxAABIVQAASFcAAEhZAABIWwAASF0AAEhfAABIYQAASGQAAEhmAABIaAAASGsAAEhtAABIbwAASHIAAEh0AABIdQAASH4AAEiLAABIjgAASJAAAEiTAABIlQAASJcAAEi8AABI4AAASQcAAEkrAABJLQAASS8AAEkxAABJMwAASTUAAEk3AABJOAAASToAAElHAABJWgAASVwAAEleAABJYAAASWIAAElkAABJZgAASWgAAElqAABJbAAASX8AAEmBAABJgwAASYUAAEmHAABJiQAASYsAAEmNAABJjwAASZEAAEmTAABJ0gAASdQAAEnWAABJ2AAASdoAAEnbAABJ3AAASd0AAEneAABJ4AAASeIAAEnjAABJ5AAASeYAAEnnAABJ8AAASfEAAEnzAABKMgAASjQAAEo2AABKOAAASjoAAEo7AABKPAAASj0AAEo+AABKQAAASkIAAEpDAABKRAAASkYAAEpHAABKhgAASogAAEqKAABKjAAASo4AAEqPAABKkAAASpEAAEqSAABKlAAASpYAAEqXAABKmAAASpoAAEqbAABKpAAASqUAAEqnAABK5gAASugAAErqAABK7AAASu4AAErvAABK8AAASvEAAEryAABK9AAASvYAAEr3AABK+AAASvoAAEr7AABLOgAASzwAAEs+AABLQAAAS0IAAEtDAABLRAAAS0UAAEtGAABLSAAAS0oAAEtLAABLTAAAS04AAEtPAABLWAAAS1kAAEtbAABLmgAAS5wAAEueAABLoAAAS6IAAEujAABLpAAAS6UAAEumAABLqAAAS6oAAEurAABLrAAAS64AAEuvAABL7gAAS/AAAEvyAABL9AAAS/YAAEv3AABL+AAAS/kAAEv6AABL/AAAS/4AAEv/AABMAAAATAIAAEwDAABMEAAATBEAAEwSAABMFAAATFMAAExVAABMVwAATFkAAExbAABMXAAATF0AAExeAABMXwAATGEAAExjAABMZAAATGUAAExnAABMaAAATKcAAEypAABMqwAATK0AAEyvAABMsAAATLEAAEyyAABMswAATLUAAEy3AABMuAAATLkAAEy7AABMvAAATMoAAEzXAABM5gAATOgAAEzqAABM7AAATO4AAEzwAABM8gAATPQAAE0DAABNBQAATQgAAE0LAABNDgAATREAAE0UAABNFwAATRkAAE0gAABNJwAATSoAAE01AABNQAAATUwAAE2XAABNugAATdoAAE36AABN/AAATf4AAE4AAABOAgAATgQAAE4FAABOBgAATggAAE4JAABOCwAATgwAAE4OAABOEAAAThEAAE4SAABOFAAAThUAAE4aAABOJwAATiwAAE4uAABOMAAATjUAAE43AABOOQAATjsAAE5gAABOhAAATqsAAE7PAABO0QAATtMAAE7VAABO1wAATtkAAE7bAABO3AAATt4AAE7rAABO/AAATv4AAE8AAABPAgAATwQAAE8GAABPCAAATwoAAE8MAABPHQAATx8AAE8hAABPIwAATyUAAE8nAABPKQAATysAAE8tAABPLwAAT24AAE9wAABPcgAAT3QAAE92AABPdwAAT3gAAE95AABPegAAT3wAAE9+AABPfwAAT4AAAE+CAABPgwAAT8IAAE/EAABPxgAAT8gAAE/KAABPywAAT8wAAE/NAABPzgAAT9AAAE/SAABP0wAAT9QAAE/WAABP1wAAUBYAAFAYAABQGgAAUBwAAFAeAABQHwAAUCAAAFAhAABQIgAAUCQAAFAmAABQJwAAUCgAAFAqAABQKwAAUDgAAFA5AABQOgAAUDwAAFB7AABQfQAAUH8AAFCBAABQgwAAUIQAAFCFAABQhgAAUIcAAFCJAABQiwAAUIwAAFCNAABQjwAAUJAAAFDPAABQ0QAAUNMAAFDVAABQ1wAAUNgAAFDZAABQ2gAAUNsAAFDdAABQ3wAAUOAAAFDhAABQ4wAAUOQAAFEjAABRJQAAUScAAFEpAABRKwAAUSwAAFEtAABRLgAAUS8AAFExAABRMwAAUTQAAFE1AABRNwAAUTgAAFF3AABReQAAUXsAAFF9AABRfwAAUYAAAFGBAABRggAAUYMAAFGFAABRhwAAUYgAAFGJAABRiwAAUYwAAFHLAABRzQAAUc8AAFHRAABR0wAAUdQAAFHVAABR1gAAUdcAAFHZAABR2wAAUdwAAFHdAABR3wAAUeAAAFIFAABSKQAAUlAAAFJ0AABSdgAAUngAAFJ6AABSfAAAUn4AAFKAAABSgQAAUoQAAFKRAABSoAAAUqIAAFKkAABSpgAAUqgAAFKqAABSrAAAUq4AAFK9AABSwAAAUsMAAFLGAABSyQAAUswAAFLPAABS0gAAUtQAAFMTAABTFQAAUxgAAFMaAABTHAAAUx0AAFMeAABTHwAAUyAAAFMiAABTJAAAUyUAAFMmAABTKAAAUykAAFMrAABTagAAU2wAAFNuAABTcAAAU3IAAFNzAABTdAAAU3UAAFN2AABTeAAAU3oAAFN7AABTfAAAU34AAFN/AABTvgAAU8AAAFPCAABTxAAAU8YAAFPHAABTyAAAU8kAAFPKAABTzAAAU84AAFPPAABT0AAAU9IAAFPTAABUEgAAVBQAAFQXAABUGQAAVBsAAFQcAABUHQAAVB4AAFQfAABUIQAAVCMAAFQkAABUJQAAVCcAAFQoAABUKgAAVGkAAFRrAABUbQAAVG8AAFRxAABUcgAAVHMAAFR0AABUdQAAVHcAAFR5AABUegAAVHsAAFR9AABUfgAAVL0AAFS/AABUwQAAVMMAAFTFAABUxgAAVMcAAFTIAABUyQAAVMsAAFTNAABUzgAAVM8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AAGBcAABgXgAAYGAAAGBjAABgZAAAYGUAAGBmAABgZwAAYGkAAGBrAABgbAAAYG0AAGBvAABgcAAAYK8AAGCxAABgswAAYLUAAGC4AABguQAAYLoAAGC7AABgvAAAYL4AAGDAAABgwQAAYMIAAGDEAABgxQAAYQQAAGEGAABhCAAAYQoAAGENAABhDgAAYQ8AAGEQAABhEQAAYRMAAGEVAABhFgAAYRcAAGEZAABhGgAAYVkAAGFbAABhXQAAYV8AAGFiAABhYwAAYWQAAGFlAABhZgAAYWgAAGFqAABhawAAYWwAAGFuAABhbwAAYa4AAGGwAABhsgAAYbQAAGG3AABhuAAAYbkAAGG6AABhuwAAYb0AAGG/AABhwAAAYcEAAGHDAABhxAAAYekAAGINAABiNAAAYlgAAGJbAABiXQAAYl8AAGJhAABiYwAAYmUAAGJmAABiaQAAYnYAAGKFAABihwAAYokAAGKLAABijQAAYo8AAGKRAABikwAAYqIAAGKlAABiqAAAYqsAAGKuAABisQAAYrQAAGK3AABiuQAAYvgAAGL6AABi/QAAYv8AAGMCAABjAwAAYwQAAGMFAABjBgAAYwgAAGMKAABjCwAAYwwAAGMOAABjDwAAY04AAGNQAABjUgAAY1QAAGNXAABjWAAAY1kAAGNaAABjWwAAY10AAGNfAABjYAAAY2EAAGNjAABjZAAAY6MAAGOlAABjpwAAY6kAAGOsAABjrQAAY64AAGOvAABjsAAAY7IAAGO0AABjtQAAY7YAAGO4AABjuQAAY/gAAGP6AABj/QAAY/8AAGQCAABkAwAAZAQAAGQFAABkBgAAZAgAAGQKAABkCwAAZAwAAGQOAABkDwAAZBEAAGRQAABkUgAAZFQAAGRWAABkWQAAZFoAAGRbAABkXAAAZF0AAGRfAABkYQAAZGIAAGRjAABkZQAAZGYAAGSlAABkpwAAZKkAAGSrAABkrgAAZK8AAGSwAABksQAAZLIAAGS0AABktgAAZLcAAGS4AABkugAAZLsAAGT6AABk/AAAZP4AAGUAAABlAwAAZQQAAGUFAABlBgAAZQcAAGUJAABlCwAAZQwAAGUNAABlDwAAZRAAAGVbAABlfgAAZZ4AAGW+AABlwAAAZcIAAGXEAABlxgAAZcgAAGXJAABlygAAZc0AAGXOAABl0AAAZdEAAGXTAABl1QAAZdYAAGXXAABl2gAAZdsAAGXgAABl7QAAZfIAAGX0AABl9gAAZfsAAGX+AABmAQAAZgMAAGYoAABmTAAAZnMAAGaXAABmmgAAZpwAAGaeAABmoAAAZqIAAGakAABmpQAAZqgAAGa1AABmxgAAZsgAAGbKAABmzAAAZs4AAGbQAABm0gAAZtQAAGbWAABm5wAAZuoAAGbtAABm8AAAZvMAAGb2AABm+QAAZvwAAGb/AABnAQAAZ0AAAGdCAABnRAAAZ0YAAGdJAABnSgAAZ0sAAGdMAABnTQAAZ08AAGdRAABnUgAAZ1MAAGdVAABnVgAAZ5UAAGeXAABnmQAAZ5sAAGeeAABnnwAAZ6AAAGehAABnogAAZ6QAAGemAABnpwAAZ6gAAGeqAABnqwAAZ+oAAGfsAABn7wAAZ/EAAGf0AABn9QAAZ/YAAGf3AABn+AAAZ/oAAGf8AABn/QAAZ/4AAGgAAABoAQAAaA4AAGgPAABoEAAAaBIAAGhRAABoUwAAaFUAAGhXAABoWgAAaFsAAGhcAABoXQAAaF4AAGhgAABoYgAAaGMAAGhkAABoZgAAaGcAAGimAABoqAAAaKoAAGisAABorwAAaLAAAGixAABosgAAaLMAAGi1AABotwAAaLgAAGi5AABouwAAaLwAAGj7AABo/QAAaP8AAGkBAABpBAAAaQUAAGkGAABpBwAAaQgAAGkKAABpDAAAaQ0AAGkOAABpEAAAaREAAGlQAABpUgAAaVQAAGlWAABpWQAAaVoAAGlbAABpXAAAaV0AAGlfAABpYQAAaWIAAGljAABpZQAAaWYAAGmlAABppwAAaakAAGmrAABprgAAaa8AAGmwAABpsQAAabIAAGm0AABptgAAabcAAGm4AABpugAAabsAAGngAABqBAAAaisAAGpPAABqUgAAalQAAGpWAABqWAAAaloAAGpcAABqXQAAamAAAGptAABqfAAAan4AAGqAAABqggAAaoQAAGqGAABqiAAAaooAAGqZAABqnAAAap8AAGqiAABqpQAAaqgAAGqrAABqrgAAarAAAGrvAABq8QAAavMAAGr1AABq+AAAavkAAGr6AABq+wAAavwAAGr+AABrAAAAawEAAGsCAABrBAAAawUAAGtEAABrRgAAa0gAAGtKAABrTQAAa04AAGtPAABrUAAAa1EAAGtTAABrVQAAa1YAAGtXAABrWQAAa1oAAGuZAABrmwAAa50AAGufAABrogAAa6MAAGukAABrpQAAa6YAAGuoAABrqgAAa6sAAGusAABrrgAAa68AAGvuAABr8AAAa/IAAGv0AABr9wAAa/gAAGv5AABr+gAAa/sAAGv9AABr/wAAbAAAAGwBAABsAwAAbAQAAGxDAABsRQAAbEcAAGxJAABsTAAAbE0AAGxOAABsTwAAbFAAAGxSAABsVAAAbFUAAGxWAABsWAAAbFkAAGyYAABsmgAAbJwAAGyeAABsoQAAbKIAAGyjAABspAAAbKUAAGynAABsqQAAbKoAAGyrAABsrQAAbK4AAGztAABs7wAAbPEAAGzzAABs9gAAbPcAAGz4AABs+QAAbPoAAGz8AABs/gAAbP8AAG0AAABtAgAAbQMAAG1OAABtcQAAbZEAAG2xAABtswAAbbUAAG23AABtuQAAbbsAAG28AABtvQAAbcAAAG3BAABtwwAAbcQAAG3GAABtyAAAbckAAG3KAABtzQAAbc4AAG3TAABt4AAAbeUAAG3nAABt6QAAbe4AAG3xAABt9AAAbfYAAG4bAABuPwAAbmYAAG6KAABujQAAbo8AAG6RAABukwAAbpUAAG6XAABumAAAbpsAAG6oAABuuQAAbrsAAG69AABuvwAAbsEAAG7DAABuxQAAbscAAG7JAABu2gAAbt0AAG7gAABu4wAAbuYAAG7pAABu7AAAbu8AAG7yAABu9AAAbzMAAG81AABvNwAAbzkAAG88AABvPQAAbz4AAG8/AABvQAAAb0IAAG9EAABvRQAAb0YAAG9IAABvSQAAb4gAAG+KAABvjAAAb44AAG+RAABvkgAAb5MAAG+UAABvlQAAb5cAAG+ZAABvmgAAb5sAAG+dAABvngAAb90AAG/fAABv4gAAb+QAAG/nAABv6AAAb+kAAG/qAABv6wAAb+0AAG/vAABv8AAAb/EAAG/zAABv9AAAcAEAAHACAABwAwAAcAUAAHBEAABwRgAAcEgAAHBKAABwTQAAcE4AAHBPAABwUAAAcFEAAHBTAABwVQAAcFYAAHBXAABwWQAAcFoAAHCZAABwmwAAcJ0AAHCfAABwogAAcKMAAHCkAABwpQAAcKYAAHCoAABwqgAAcKsAAHCsAABwrgAAcK8AAHDuAABw8AAAcPIAAHD0AABw9wAAcPgAAHD5AABw+gAAcPsAAHD9AABw/wAAcQAAAHEBAABxAwAAcQQAAHFDAABxRQAAcUcAAHFJAABxTAAAcU0AAHFOAABxTwAAcVAAAHFSAABxVAAAcVUAAHFWAABxWAAAcVkAAHGYAABxmgAAcZwAAHGeAABxoQAAcaIAAHGjAABxpAAAcaUAAHGnAABxqQAAcaoAAHGrAABxrQAAca4AAHHTAABx9wAAch4AAHJCAAByRQAAckcAAHJJAABySwAAck0AAHJPAAByUAAAclMAAHJgAABybwAAcnEAAHJzAABydQAAcncAAHJ5AAByewAAcn0AAHKMAAByjwAAcpIAAHKVAABymAAAcpsAAHKeAAByoQAAcqMAAHLiAABy5AAAcucAAHLpAABy7AAAcu0AAHLuAABy7wAAcvAAAHLyAABy9AAAcvUAAHL2AABy+AAAcvkAAHM4AABzOgAAczwAAHM+AABzQQAAc0IAAHNDAABzRAAAc0UAAHNHAABzSQAAc0oAAHNLAABzTQAAc04AAHONAABzjwAAc5EAAHOTAABzlgAAc5cAAHOYAABzmQAAc5oAAHOcAABzngAAc58AAHOgAABzogAAc6MAAHPiAABz5AAAc+cAAHPpAABz7AAAc+0AAHPuAABz7wAAc/AAAHPyAABz9AAAc/UAAHP2AABz+AAAc/kAAHQ4AAB0OgAAdDwAAHQ+AAB0QQAAdEIAAHRDAAB0RAAAdEUAAHRHAAB0SQAAdEoAAHRLAAB0TQAAdE4AAHSNAAB0jwAAdJEAAHSTAAB0lgAAdJcAAHSYAAB0mQAAdJoAAHScAAB0ngAAdJ8AAHSgAAB0ogAAdKMAAHTiAAB05AAAdOYAAHToAAB06wAAdOwAAHTtAAB07gAAdO8AAHTxAAB08wAAdPQAAHT1AAB09wAAdPgAAHVDAAB1ZgAAdYYAAHWmAAB1qAAAdaoAAHWsAAB1rgAAdbAAAHWxAAB1sgAAdbUAAHW2AAB1uAAAdbkAAHW7AAB1vQAAdb4AAHW/AAB1wgAAdcMAAHXIAAB11QAAddoAAHXcAAB13gAAdeMAAHXmAAB16QAAdesAAHYQAAB2NAAAdlsAAHZ/AAB2ggAAdoQAAHaGAAB2iAAAdooAAHaMAAB2jQAAdpAAAHadAAB2rgAAdrAAAHayAAB2tAAAdrYAAHa4AAB2ugAAdrwAAHa+AAB2zwAAdtIAAHbVAAB22AAAdtsAAHbeAAB24QAAduQAAHbnAAB26QAAdygAAHcqAAB3LAAAdy4AAHcxAAB3MgAAdzMAAHc0AAB3NQAAdzcAAHc5AAB3OgAAdzsAAHc9AAB3PgAAd30AAHd/AAB3gQAAd4MAAHeGAAB3hwAAd4gAAHeJAAB3igAAd4wAAHeOAAB3jwAAd5AAAHeSAAB3kwAAd9IAAHfUAAB31wAAd9kAAHfcAAB33QAAd94AAHffAAB34AAAd+IAAHfkAAB35QAAd+YAAHfoAAB36QAAd/YAAHf3AAB3+AAAd/oAAHg5AAB4OwAAeD0AAHg/AAB4QgAAeEMAAHhEAAB4RQAAeEYAAHhIAAB4SgAAeEsAAHhMAAB4TgAAeE8AAHiOAAB4kAAAeJIAAHiUAAB4lwAAeJgAAHiZAAB4mgAAeJsAAHidAAB4nwAAeKAAAHihAAB4owAAeKQAAHjjAAB45QAAeOcAAHjpAAB47AAAeO0AAHjuAAB47wAAePAAAHjyAAB49AAAePUAAHj2AAB4+AAAePkAAHk4AAB5OgAAeTwAAHk+AAB5QQAAeUIAAHlDAAB5RAAAeUUAAHlHAAB5SQAAeUoAAHlLAAB5TQAAeU4AAHmNAAB5jwAAeZEAAHmTAAB5lgAAeZcAAHmYAAB5mQAAeZoAAHmcAAB5ngAAeZ8AAHmgAAB5ogAAeaMAAHnIAAB57AAAehMAAHo3AAB6OgAAejwAAHo+AAB6QAAAekIAAHpEAAB6RQAAekgAAHpVAAB6ZAAAemYAAHpoAAB6agAAemwAAHpuAAB6cAAAenIAAHqBAAB6hAAAeocAAHqKAAB6jQAAepAAAHqTAAB6lgAAepgAAHrXAAB62QAAetsAAHrdAAB64AAAeuEAAHriAAB64wAAeuQAAHrmAAB66AAAeukAAHrqAAB67AAAeu0AAHssAAB7LgAAezAAAHsyAAB7NQAAezYAAHs3AAB7OAAAezkAAHs7AAB7PQAAez4AAHs/AAB7QQAAe0IAAHuBAAB7gwAAe4UAAHuHAAB7igAAe4sAAHuMAAB7jQAAe44AAHuQAAB7kgAAe5MAAHuUAAB7lgAAe5cAAHvWAAB72AAAe9sAAHvdAAB74AAAe+EAAHviAAB74wAAe+QAAHvmAAB76AAAe+kAAHvqAAB77AAAe+0AAHvwAAB8LwAAfDEAAHwzAAB8NQAAfDgAAHw5AAB8OgAAfDsAAHw8AAB8PgAAfEAAAHxBAAB8QgAAfEQAAHxFAAB8hAAAfIYAAHyIAAB8igAAfI0AAHyOAAB8jwAAfJAAAHyRAAB8kwAAfJUAAHyWAAB8lwAAfJkAAHyaAAB82QAAfNsAAHzdAAB83wAAfOIAAHzjAAB85AAAfOUAAHzmAAB86AAAfOoAAHzrAAB87AAAfO4AAHzvAAB9OgAAfV0AAH19AAB9nQAAfZ8AAH2hAAB9owAAfaUAAH2nAAB9qAAAfakAAH2sAAB9rQAAfa8AAH2wAAB9sgAAfbQAAH21AAB9tgAAfbkAAH26AAB9vwAAfcwAAH3RAAB90wAAfdUAAH3aAAB93QAAfeAAAH3iAAB+BwAAfisAAH5SAAB+dgAAfnkAAH57AAB+fQAAfn8AAH6BAAB+gwAAfoQAAH6HAAB+lAAAfqUAAH6nAAB+qQAAfqsAAH6tAAB+rwAAfrEAAH6zAAB+tQAAfsYAAH7JAAB+zAAAfs8AAH7SAAB+1QAAftgAAH7bAAB+3gAAfuAAAH8fAAB/IQAAfyMAAH8lAAB/KAAAfykAAH8qAAB/KwAAfywAAH8uAAB/MAAAfzEAAH8yAAB/NAAAfzUAAH90AAB/dgAAf3gAAH96AAB/fQAAf34AAH9/AAB/gAAAf4EAAH+DAAB/hQAAf4YAAH+HAAB/iQAAf4oAAH/JAAB/ywAAf84AAH/QAAB/0wAAf9QAAH/VAAB/1gAAf9cAAH/ZAAB/2wAAf9wAAH/dAAB/3wAAf+AAAH/tAAB/7gAAf+8AAH/xAACAMAAAgDIAAIA0AACANgAAgDkAAIA6AACAOwAAgDwAAIA9AACAPwAAgEEAAIBCAACAQwAAgEUAAIBGAACAhQAAgIcAAICJAACAiwAAgI4AAICPAACAkAAAgJEAAICSAACAlAAAgJYAAICXAACAmAAAgJoAAICbAACA2gAAgNwAAIDeAACA4AAAgOMAAIDkAACA5QAAgOYAAIDnAACA6QAAgOsAAIDsAACA7QAAgO8AAIDwAACBLwAAgTEAAIEzAACBNQAAgTgAAIE5AACBOgAAgTsAAIE8AACBPgAAgUAAAIFBAACBQgAAgUQAAIFFAACBhAAAgYYAAIGIAACBigAAgY0AAIGOAACBjwAAgZAAAIGRAACBkwAAgZUAAIGWAACBlwAAgZkAAIGaAACBvwAAgeMAAIIKAACCLgAAgjEAAIIzAACCNQAAgjcAAII5AACCOwAAgjwAAII/AACCTAAAglsAAIJdAACCXwAAgmEAAIJjAACCZQAAgmcAAIJpAACCeAAAgnsAAIJ+AACCgQAAgoQAAIKHAACCigAAgo0AAIKPAACCzgAAgtAAAILSAACC1AAAgtcAAILYAACC2QAAgtoAAILbAACC3QAAgt8AAILgAACC4QAAguMAAILkAACDIwAAgyUAAIMnAACDKQAAgywAAIMtAACDLgAAgy8AAIMwAACDMgAAgzQAAIM1AACDNgAAgzgAAIM5AACDeAAAg3oAAIN8AACDfgAAg4EAAIOCAACDgwAAg4QAAIOFAACDhwAAg4kAAIOKAACDiwAAg40AAIOOAACDzQAAg88AAIPRAACD0wAAg9YAAIPXAACD2AAAg9kAAIPaAACD3AAAg94AAIPfAACD4AAAg+IAAIPjAACEIgAAhCQAAIQmAACEKAAAhCsAAIQsAACELQAAhC4AAIQvAACEMQAAhDMAAIQ0AACENQAAhDcAAIQ4AACEdwAAhHkAAIR7AACEfQAAhIAAAISBAACEggAAhIMAAISEAACEhgAAhIgAAISJAACEigAAhIwAAISNAACEzAAAhM4AAITQAACE0gAAhNUAAITWAACE1wAAhNgAAITZAACE2wAAhN0AAITeAACE3wAAhOEAAITiAACE7QAAhPYAAIT3AACE+QAAhQIAAIUNAACFHAAAhScAAIU1AACFSgAAhV4AAIV1AACFhwAAhcYAAIXIAACFygAAhcwAAIXOAACFzwAAhdAAAIXRAACF0gAAhdQAAIXWAACF1wAAhdgAAIXaAACF2wAAhhoAAIYcAACGHgAAhiAAAIYiAACGIwAAhiQAAIYlAACGJgAAhigAAIYqAACGKwAAhiwAAIYuAACGLwAAhm4AAIZwAACGcwAAhnUAAIZ3AACGeAAAhnkAAIZ6AACGewAAhn0AAIZ/AACGgAAAhoEAAIaDAACGhAAAhs8AAIbyAACHEgAAhzIAAIc0AACHNgAAhzgAAIc6AACHPAAAhz0AAIc+AACHQQAAh0IAAIdEAACHRQAAh0cAAIdJAACHSgAAh0sAAIdOAACHTwAAh1QAAIdhAACHZgAAh2gAAIdqAACHbwAAh3IAAId1AACHdwAAh5wAAIfAAACH5wAAiAsAAIgOAACIEAAAiBIAAIgUAACIFgAAiBgAAIgZAACIHAAAiCkAAIg6AACIPAAAiD4AAIhAAACIQgAAiEQAAIhGAACISAAAiEoAAIhbAACIXgAAiGEAAIhkAACIZwAAiGoAAIhtAACIcAAAiHMAAIh1AACItAAAiLYAAIi4AACIugAAiL0AAIi+AACIvwAAiMAAAIjBAACIwwAAiMUAAIjGAACIxwAAiMkAAIjKAACJCQAAiQsAAIkNAACJDwAAiRIAAIkTAACJFAAAiRUAAIkWAACJGAAAiRoAAIkbAACJHAAAiR4AAIkfAACJXgAAiWAAAIljAACJZQAAiWgAAIlpAACJagAAiWsAAIlsAACJbgAAiXAAAIlxAACJcgAAiXQAAIl1AACJggAAiYMAAImEAACJhgAAicUAAInHAACJyQAAicsAAInOAACJzwAAidAAAInRAACJ0gAAidQAAInWAACJ1wAAidgAAInaAACJ2wAAihoAAIocAACKHgAAiiAAAIojAACKJAAAiiUAAIomAACKJwAAiikAAIorAACKLAAAii0AAIovAACKMAAAim8AAIpxAACKcwAAinUAAIp4AACKeQAAinoAAIp7AACKfAAAin4AAIqAAACKgQAAioIAAIqEAACKhQAAisQAAIrGAACKyAAAisoAAIrNAACKzgAAis8AAIrQAACK0QAAitMAAIrVAACK1gAAitcAAIrZAACK2gAAixkAAIsbAACLHQAAix8AAIsiAACLIwAAiyQAAIslAACLJgAAiygAAIsqAACLKwAAiywAAIsuAACLLwAAi1QAAIt4AACLnwAAi8MAAIvGAACLyAAAi8oAAIvMAACLzgAAi9AAAIvRAACL1AAAi+EAAIvwAACL8gAAi/QAAIv2AACL+AAAi/oAAIv8AACL/gAAjA0AAIwQAACMEwAAjBYAAIwZAACMHAAAjB8AAIwiAACMJAAAjGMAAIxlAACMaAAAjGoAAIxtAACMbgAAjG8AAIxwAACMcQAAjHMAAIx1AACMdgAAjHcAAIx5AACMegAAjH4AAIy9AACMvwAAjMEAAIzDAACMxgAAjMcAAIzIAACMyQAAjMoAAIzMAACMzgAAjM8AAIzQAACM0gAAjNMAAI0SAACNFAAAjRYAAI0YAACNGwAAjRwAAI0dAACNHgAAjR8AAI0hAACNIwAAjSQAAI0lAACNJwAAjSgAAI1nAACNaQAAjWwAAI1uAACNcQAAjXIAAI1zAACNdAAAjXUAAI13AACNeQAAjXoAAI17AACNfQAAjX4AAI2BAACNwAAAjcIAAI3EAACNxgAAjckAAI3KAACNywAAjcwAAI3NAACNzwAAjdEAAI3SAACN0wAAjdUAAI3WAACOFQAAjhcAAI4ZAACOGwAAjh4AAI4fAACOIAAAjiEAAI4iAACOJAAAjiYAAI4nAACOKAAAjioAAI4rAACOagAAjmwAAI5uAACOcAAAjnMAAI50AACOdQAAjnYAAI53AACOeQAAjnsAAI58AACOfQAAjn8AAI6AAACOywAAju4AAI8OAACPLgAAjzAAAI8yAACPNAAAjzYAAI84AACPOQAAjzoAAI89AACPPgAAj0AAAI9BAACPQwAAj0UAAI9GAACPRwAAj0oAAI9LAACPUAAAj10AAI9iAACPZAAAj2YAAI9rAACPbgAAj3EAAI9zAACPmAAAj7wAAI/jAACQBwAAkAoAAJAMAACQDgAAkBAAAJASAACQFAAAkBUAAJAYAACQJQAAkDYAAJA4AACQOgAAkDwAAJA+AACQQAAAkEIAAJBEAACQRgAAkFcAAJBaAACQXQAAkGAAAJBjAACQZgAAkGkAAJBsAACQbwAAkHEAAJCwAACQsgAAkLQAAJC2AACQuQAAkLoAAJC7AACQvAAAkL0AAJC/AACQwQAAkMIAAJDDAACQxQAAkMYAAJEFAACRBwAAkQkAAJELAACRDgAAkQ8AAJEQAACREQAAkRIAAJEUAACRFgAAkRcAAJEYAACRGgAAkRsAAJFaAACRXAAAkV8AAJFhAACRZAAAkWUAAJFmAACRZwAAkWgAAJFqAACRbAAAkW0AAJFuAACRcAAAkXEAAJF+AACRfwAAkYAAAJGCAACRwQAAkcMAAJHFAACRxwAAkcoAAJHLAACRzAAAkc0AAJHOAACR0AAAkdIAAJHTAACR1AAAkdYAAJHXAACSFgAAkhgAAJIaAACSHAAAkh8AAJIgAACSIQAAkiIAAJIjAACSJQAAkicAAJIoAACSKQAAkisAAJIsAACSawAAkm0AAJJvAACScQAAknQAAJJ1AACSdgAAkncAAJJ4AACSegAAknwAAJJ9AACSfgAAkoAAAJKBAACSwAAAksIAAJLEAACSxgAAkskAAJLKAACSywAAkswAAJLNAACSzwAAktEAAJLSAACS0wAAktUAAJLWAACTFQAAkxcAAJMZAACTGwAAkx4AAJMfAACTIAAAkyEAAJMiAACTJAAAkyYAAJMnAACTKAAAkyoAAJMrAACTUAAAk3QAAJObAACTvwAAk8IAAJPEAACTxgAAk8gAAJPKAACTzAAAk80AAJPQAACT3QAAk+wAAJPuAACT8AAAk/IAAJP0AACT9gAAk/gAAJP6AACUCQAAlAwAAJQPAACUEgAAlBUAAJQYAACUGwAAlB4AAJQgAACUXwAAlGEAAJRkAACUZgAAlGkAAJRqAACUawAAlGwAAJRtAACUbwAAlHEAAJRyAACUcwAAlHUAAJR2AACUtQAAlLcAAJS5AACUuwAAlL4AAJS/AACUwAAAlMEAAJTCAACUxAAAlMYAAJTHAACUyAAAlMoAAJTLAACVCgAAlQwAAJUOAACVEAAAlRMAAJUUAACVFQAAlRYAAJUXAACVGQAAlRsAAJUcAACVHQAAlR8AAJUgAACVXwAAlWEAAJVkAACVZgAAlWkAAJVqAACVawAAlWwAAJVtAACVbwAAlXEAAJVyAACVcwAAlXUAAJV2AACVtQAAlbcAAJW5AACVuwAAlb4AAJW/AACVwAAAlcEAAJXCAACVxAAAlcYAAJXHAACVyAAAlcoAAJXLAACWCgAAlgwAAJYOAACWEAAAlhMAAJYUAACWFQAAlhYAAJYXAACWGQAAlhsAAJYcAACWHQAAlh8AAJYgAACWXwAAlmEAAJZjAACWZQAAlmgAAJZpAACWagAAlmsAAJZsAACWbgAAlnAAAJZxAACWcgAAlnQAAJZ1AACWwAAAluMAAJcDAACXIwAAlyUAAJcnAACXKQAAlysAAJctAACXLgAAly8AAJcyAACXMwAAlzUAAJc2AACXOAAAlzoAAJc7AACXPAAAlz8AAJdAAACXRQAAl1IAAJdXAACXWQAAl1sAAJdgAACXYwAAl2YAAJdoAACXjQAAl7EAAJfYAACX/AAAl/8AAJgBAACYAwAAmAUAAJgHAACYCQAAmAoAAJgNAACYGgAAmCsAAJgtAACYLwAAmDEAAJgzAACYNQAAmDcAAJg5AACYOwAAmEwAAJhPAACYUgAAmFUAAJhYAACYWwAAmF4AAJhhAACYZAAAmGYAAJilAACYpwAAmKkAAJirAACYrgAAmK8AAJiwAACYsQAAmLIAAJi0AACYtgAAmLcAAJi4AACYugAAmLsAAJj6AACY/AAAmP4AAJkAAACZAwAAmQQAAJkFAACZBgAAmQcAAJkJAACZCwAAmQwAAJkNAACZDwAAmRAAAJlPAACZUQAAmVQAAJlWAACZWQAAmVoAAJlbAACZXAAAmV0AAJlfAACZYQAAmWIAAJljAACZZQAAmWYAAJlzAACZdAAAmXUAAJl3AACZtgAAmbgAAJm6AACZvAAAmb8AAJnAAACZwQAAmcIAAJnDAACZxQAAmccAAJnIAACZyQAAmcsAAJnMAACaCwAAmg0AAJoPAACaEQAAmhQAAJoVAACaFgAAmhcAAJoYAACaGgAAmhwAAJodAACaHgAAmiAAAJohAACaYAAAmmIAAJpkAACaZgAAmmkAAJpqAACaawAAmmwAAJptAACabwAAmnEAAJpyAACacwAAmnUAAJp2AACatQAAmrcAAJq5AACauwAAmr4AAJq/AACawAAAmsEAAJrCAACaxAAAmsYAAJrHAACayAAAmsoAAJrLAACbCgAAmwwAAJsOAACbEAAAmxMAAJsUAACbFQAAmxYAAJsXAACbGQAAmxsAAJscAACbHQAAmx8AAJsgAACbRQAAm2kAAJuQAACbtAAAm7cAAJu5AACbuwAAm70AAJu/AACbwQAAm8IAAJvFAACb0gAAm+EAAJvjAACb5QAAm+cAAJvpAACb6wAAm+0AAJvvAACb/gAAnAEAAJwEAACcBwAAnAoAAJwNAACcEAAAnBMAAJwVAACcVAAAnFYAAJxYAACcWgAAnF0AAJxeAACcXwAAnGAAAJxhAACcYwAAnGUAAJxmAACcZwAAnGkAAJxqAACcqQAAnKsAAJytAACcrwAAnLIAAJyzAACctAAAnLUAAJy2AACcuAAAnLoAAJy7AACcvAAAnL4AAJy/AACc/gAAnQAAAJ0CAACdBAAAnQcAAJ0IAACdCQAAnQoAAJ0LAACdDQAAnQ8AAJ0QAACdEQAAnRMAAJ0UAACdUwAAnVUAAJ1XAACdWQAAnVwAAJ1dAACdXgAAnV8AAJ1gAACdYgAAnWQAAJ1lAACdZgAAnWgAAJ1pAACdqAAAnaoAAJ2sAACdrgAAnbEAAJ2yAACdswAAnbQAAJ21AACdtwAAnbkAAJ26AACduwAAnb0AAJ2+AACd/QAAnf8AAJ4BAACeAwAAngYAAJ4HAACeCAAAngkAAJ4KAACeDAAAng4AAJ4PAACeEAAAnhIAAJ4TAACeUgAAnlQAAJ5WAACeWAAAnlsAAJ5cAACeXQAAnl4AAJ5fAACeYQAAnmMAAJ5kAACeZQAAnmcAAJ5oAACeswAAntYAAJ72AACfFgAAnxgAAJ8aAACfHAAAnx4AAJ8gAACfIQAAnyIAAJ8lAACfJgAAnygAAJ8pAACfKwAAny0AAJ8uAACfLwAAnzIAAJ8zAACfOAAAn0UAAJ9KAACfTAAAn04AAJ9TAACfVgAAn1kAAJ9bAACfgAAAn6QAAJ/LAACf7wAAn/IAAJ/0AACf9gAAn/gAAJ/6AACf/AAAn/0AAKAAAACgDQAAoB4AAKAgAACgIgAAoCQAAKAmAACgKAAAoCoAAKAsAACgLgAAoD8AAKBCAACgRQAAoEgAAKBLAACgTgAAoFEAAKBUAACgVwAAoFkAAKCYAACgmgAAoJwAAKCeAACgoQAAoKIAAKCjAACgpAAAoKUAAKCnAACgqQAAoKoAAKCrAACgrQAAoK4AAKDtAACg7wAAoPEAAKDzAACg9gAAoPcAAKD4AACg+QAAoPoAAKD8AACg/gAAoP8AAKEAAAChAgAAoQMAAKFCAAChRAAAoUcAAKFJAAChTAAAoU0AAKFOAAChTwAAoVAAAKFSAAChVAAAoVUAAKFWAAChWAAAoVkAAKFmAAChZwAAoWgAAKFqAAChqQAAoasAAKGtAAChrwAAobIAAKGzAAChtAAAobUAAKG2AAChuAAAoboAAKG7AAChvAAAob4AAKG/AACh/gAAogAAAKICAACiBAAAogcAAKIIAACiCQAAogoAAKILAACiDQAAog8AAKIQAACiEQAAohMAAKIUAACiUwAAolUAAKJXAACiWQAAolwAAKJdAACiXgAAol8AAKJgAACiYgAAomQAAKJlAACiZgAAomgAAKJpAACiqAAAoqoAAKKsAACirgAAorEAAKKyAACiswAAorQAAKK1AACitwAAorkAAKK6AACiuwAAor0AAKK+AACi/QAAov8AAKMBAACjAwAAowYAAKMHAACjCAAAowkAAKMKAACjDAAAow4AAKMPAACjEAAAoxIAAKMTAACjOAAAo1wAAKODAACjpwAAo6oAAKOsAACjrgAAo7AAAKOyAACjtAAAo7UAAKO4AACjxQAAo9QAAKPWAACj2AAAo9oAAKPcAACj3gAAo+AAAKPiAACj8QAAo/QAAKP3AACj+gAAo/0AAKQAAACkAwAApAYAAKQIAACkRwAApEkAAKRMAACkTgAApFEAAKRSAACkUwAApFQAAKRVAACkVwAApFkAAKRaAACkWwAApF0AAKReAACkYAAApJ8AAKShAACkowAApKUAAKSoAACkqQAApKoAAKSrAACkrAAApK4AAKSwAACksQAApLIAAKS0AACktQAApPQAAKT2AACk+AAApPoAAKT9AACk/gAApP8AAKUAAAClAQAApQMAAKUFAAClBgAApQcAAKUJAAClCgAApUkAAKVLAAClTgAApVAAAKVTAAClVAAApVUAAKVWAAClVwAApVkAAKVbAAClXAAApV0AAKVfAAClYAAApWMAAKWiAAClpAAApaYAAKWoAAClqwAApawAAKWtAAClrgAApa8AAKWxAAClswAApbQAAKW1AACltwAApbgAAKX3AACl+QAApfsAAKX9AACmAAAApgEAAKYCAACmAwAApgQAAKYGAACmCAAApgkAAKYKAACmDAAApg0AAKZMAACmTgAAplAAAKZSAACmVQAAplYAAKZXAACmWAAAplkAAKZbAACmXQAApl4AAKZfAACmYQAApmIAAKatAACm0AAApvAAAKcQAACnEgAApxQAAKcWAACnGAAApxoAAKcbAACnHAAApx8AAKcgAACnIgAApyMAAKclAACnJwAApygAAKcpAACnLAAApy0AAKc2AACnQwAAp0gAAKdKAACnTAAAp1EAAKdUAACnVwAAp1kAAKd+AACnogAAp8kAAKftAACn8AAAp/IAAKf0AACn9gAAp/gAAKf6AACn+wAAp/4AAKgLAACoHAAAqB4AAKggAACoIgAAqCQAAKgmAACoKAAAqCoAAKgsAACoPQAAqEAAAKhDAACoRgAAqEkAAKhMAACoTwAAqFIAAKhVAACoVwAAqJYAAKiYAAComgAAqJwAAKifAACooAAAqKEAAKiiAACoowAAqKUAAKinAACoqAAAqKkAAKirAACorAAAqOsAAKjtAACo7wAAqPEAAKj0AACo9QAAqPYAAKj3AACo+AAAqPoAAKj8AACo/QAAqP4AAKkAAACpAQAAqUAAAKlCAACpRQAAqUcAAKlKAACpSwAAqUwAAKlNAACpTgAAqVAAAKlSAACpUwAAqVQAAKlWAACpVwAAqWQAAKllAACpZgAAqWgAAKmnAACpqQAAqasAAKmtAACpsAAAqbEAAKmyAACpswAAqbQAAKm2AACpuAAAqbkAAKm6AACpvAAAqb0AAKn8AACp/gAAqgAAAKoCAACqBQAAqgYAAKoHAACqCAAAqgkAAKoLAACqDQAAqg4AAKoPAACqEQAAqhIAAKpRAACqUwAAqlUAAKpXAACqWgAAqlsAAKpcAACqXQAAql4AAKpgAACqYgAAqmMAAKpkAACqZgAAqmcAAKqmAACqqAAAqqoAAKqsAACqrwAAqrAAAKqxAACqsgAAqrMAAKq1AACqtwAAqrgAAKq5AACquwAAqrwAAKr7AACq/QAAqv8AAKsBAACrBAAAqwUAAKsGAACrBwAAqwgAAKsKAACrDAAAqw0AAKsOAACrEAAAqxEAAKs2AACrWgAAq4EAAKulAACrqAAAq6oAAKusAACrrgAAq7AAAKuyAACrswAAq7YAAKvDAACr0gAAq9QAAKvWAACr2AAAq9oAAKvcAACr3gAAq+AAAKvvAACr8gAAq/UAAKv4AACr+wAAq/4AAKwBAACsBAAArAYAAKxFAACsRwAArEoAAKxMAACsTwAArFAAAKxRAACsUgAArFMAAKxVAACsVwAArFgAAKxZAACsWwAArFwAAKybAACsnQAArJ8AAKyhAACspAAArKUAAKymAACspwAArKgAAKyqAACsrAAArK0AAKyuAACssAAArLEAAKzwAACs8gAArPQAAKz2AACs+QAArPoAAKz7AACs/AAArP0AAKz/AACtAQAArQIAAK0DAACtBQAArQYAAK1FAACtRwAArUoAAK1MAACtTwAArVAAAK1RAACtUgAArVMAAK1VAACtVwAArVgAAK1ZAACtWwAArVwAAK2bAACtnQAArZ8AAK2hAACtpAAAraUAAK2mAACtpwAAragAAK2qAACtrAAAra0AAK2uAACtsAAArbEAAK3wAACt8gAArfQAAK32AACt+QAArfoAAK37AACt/AAArf0AAK3/AACuAQAArgIAAK4DAACuBQAArgYAAK5FAACuRwAArkkAAK5LAACuTgAArk8AAK5QAACuUQAArlIAAK5UAACuVgAArlcAAK5YAACuWgAArlsAAK6mAACuyQAArukAAK8JAACvCwAArw0AAK8PAACvEQAArxMAAK8UAACvFQAArxgAAK8ZAACvGwAArxwAAK8eAACvIAAAryEAAK8iAACvJQAAryYAAK8vAACvPAAAr0EAAK9DAACvRQAAr0oAAK9NAACvUAAAr1IAAK93AACvmwAAr8IAAK/mAACv6QAAr+sAAK/tAACv7wAAr/EAAK/zAACv9AAAr/cAALAEAACwFQAAsBcAALAZAACwGwAAsB0AALAfAACwIQAAsCMAALAlAACwNgAAsDkAALA8AACwPwAAsEIAALBFAACwSAAAsEsAALBOAACwUAAAsI8AALCRAACwkwAAsJUAALCYAACwmQAAsJoAALCbAACwnAAAsJ4AALCgAACwoQAAsKIAALCkAACwpQAAsOQAALDmAACw6AAAsOoAALDtAACw7gAAsO8AALDwAACw8QAAsPMAALD1AACw9gAAsPcAALD5AACw+gAAsTkAALE7AACxPgAAsUAAALFDAACxRAAAsUUAALFGAACxRwAAsUkAALFLAACxTAAAsU0AALFPAACxUAAAsV0AALFeAACxXwAAsWEAALGgAACxogAAsaQAALGmAACxqQAAsaoAALGrAACxrAAAsa0AALGvAACxsQAAsbIAALGzAACxtQAAsbYAALH1AACx9wAAsfkAALH7AACx/gAAsf8AALIAAACyAQAAsgIAALIEAACyBgAAsgcAALIIAACyCgAAsgsAALJKAACyTAAAsk4AALJQAACyUwAAslQAALJVAACyVgAAslcAALJZAACyWwAAslwAALJdAACyXwAAsmAAALKfAACyoQAAsqMAALKlAACyqAAAsqkAALKqAACyqwAAsqwAALKuAACysAAAsrEAALKyAACytAAAsrUAALL0AACy9gAAsvgAALL6AACy/QAAsv4AALL/AACzAAAAswEAALMDAACzBQAAswYAALMHAACzCQAAswoAALMvAACzUwAAs3oAALOeAACzoQAAs6MAALOlAACzpwAAs6kAALOrAACzrAAAs68AALO8AACzywAAs80AALPPAACz0QAAs9MAALPVAACz1wAAs9kAALPoAACz6wAAs+4AALPxAACz9AAAs/cAALP6AACz/QAAs/8AALQ+AAC0QAAAtEIAALREAAC0RwAAtEgAALRJAAC0SgAAtEsAALRNAAC0TwAAtFAAALRRAAC0UwAAtFQAALSTAAC0lQAAtJcAALSZAAC0nAAAtJ0AALSeAAC0nwAAtKAAALSiAAC0pAAAtKUAALSmAAC0qAAAtKkAALToAAC06gAAtOwAALTuAAC08QAAtPIAALTzAAC09AAAtPUAALT3AAC0+QAAtPoAALT7AAC0/QAAtP4AALU9AAC1PwAAtUEAALVDAAC1RgAAtUcAALVIAAC1SQAAtUoAALVMAAC1TgAAtU8AALVQAAC1UgAAtVMAALWSAAC1lAAAtZYAALWYAAC1mwAAtZwAALWdAAC1ngAAtZ8AALWhAAC1owAAtaQAALWlAAC1pwAAtagAALXnAAC16QAAtesAALXtAAC18AAAtfEAALXyAAC18wAAtfQAALX2AAC1+AAAtfkAALX6AAC1/AAAtf0AALY8AAC2PgAAtkAAALZCAAC2RQAAtkYAALZHAAC2SAAAtkkAALZLAAC2TQAAtk4AALZPAAC2UQAAtlIAALadAAC2wAAAtuAAALcAAAC3AgAAtwQAALcGAAC3CAAAtwoAALcLAAC3DAAAtw8AALcQAAC3EgAAtxMAALcVAAC3FwAAtxgAALcZAAC3HAAAtx0AALciAAC3LwAAtzQAALc2AAC3OAAAtz0AALdAAAC3QwAAt0UAALdqAAC3jgAAt7UAALfZAAC33AAAt94AALfgAAC34gAAt+QAALfmAAC35wAAt+oAALf3AAC4CAAAuAoAALgMAAC4DgAAuBAAALgSAAC4FAAAuBYAALgYAAC4KQAAuCwAALgvAAC4MgAAuDUAALg4AAC4OwAAuD4AALhBAAC4QwAAuIIAALiEAAC4hgAAuIgAALiLAAC4jAAAuI0AALiOAAC4jwAAuJEAALiTAAC4lAAAuJUAALiXAAC4mAAAuNcAALjZAAC42wAAuN0AALjgAAC44QAAuOIAALjjAAC45AAAuOYAALjoAAC46QAAuOoAALjsAAC47QAAuSwAALkuAAC5MQAAuTMAALk2AAC5NwAAuTgAALk5AAC5OgAAuTwAALk+AAC5PwAAuUAAALlCAAC5QwAAuVAAALlRAAC5UgAAuVQAALmTAAC5lQAAuZcAALmZAAC5nAAAuZ0AALmeAAC5nwAAuaAAALmiAAC5pAAAuaUAALmmAAC5qAAAuakAALnoAAC56gAAuewAALnuAAC58QAAufIAALnzAAC59AAAufUAALn3AAC5+QAAufoAALn7AAC5/QAAuf4AALo9AAC6PwAAukEAALpDAAC6RgAAukcAALpIAAC6SQAAukoAALpMAAC6TgAAuk8AALpQAAC6UgAAulMAALqSAAC6lAAAupYAALqYAAC6mwAAupwAALqdAAC6ngAAup8AALqhAAC6owAAuqQAALqlAAC6pwAAuqgAALrnAAC66QAAuusAALrtAAC68AAAuvEAALryAAC68wAAuvQAALr2AAC6+AAAuvkAALr6AAC6/AAAuv0AALsiAAC7RgAAu20AALuRAAC7lAAAu5YAALuYAAC7mgAAu5wAALueAAC7nwAAu6IAALuvAAC7vgAAu8AAALvCAAC7xAAAu8YAALvIAAC7ygAAu8wAALvbAAC73gAAu+EAALvkAAC75wAAu+oAALvtAAC78AAAu/IAALwxAAC8MwAAvDUAALw3AAC8OgAAvDsAALw8AAC8PQAAvD4AALxAAAC8QgAAvEMAALxEAAC8RgAAvEcAALyGAAC8iAAAvIoAALyMAAC8jwAAvJAAALyRAAC8kgAAvJMAALyVAAC8lwAAvJgAALyZAAC8mwAAvJwAALzbAAC83QAAvN8AALzhAAC85AAAvOUAALzmAAC85wAAvOgAALzqAAC87AAAvO0AALzuAAC88AAAvPEAAL0wAAC9MgAAvTUAAL03AAC9OgAAvTsAAL08AAC9PQAAvT4AAL1AAAC9QgAAvUMAAL1EAAC9RgAAvUcAAL1KAAC9iQAAvYsAAL2NAAC9jwAAvZIAAL2TAAC9lAAAvZUAAL2WAAC9mAAAvZoAAL2bAAC9nAAAvZ4AAL2fAAC93gAAveAAAL3iAAC95AAAvecAAL3oAAC96QAAveoAAL3rAAC97QAAve8AAL3wAAC98QAAvfMAAL30AAC+MwAAvjUAAL43AAC+OQAAvjwAAL49AAC+PgAAvj8AAL5AAAC+QgAAvkQAAL5FAAC+RgAAvkgAAL5JAAC+lAAAvrcAAL7XAAC+9wAAvvkAAL77AAC+/QAAvv8AAL8BAAC/AgAAvwMAAL8GAAC/BwAAvwkAAL8KAAC/DAAAvw4AAL8PAAC/EAAAvxMAAL8UAAC/GQAAvyYAAL8rAAC/LQAAvy8AAL80AAC/NwAAvzoAAL88AAC/YQAAv4UAAL+sAAC/0AAAv9MAAL/VAAC/1wAAv9kAAL/bAAC/3QAAv94AAL/hAAC/7gAAv/8AAMABAADAAwAAwAUAAMAHAADACQAAwAsAAMANAADADwAAwCAAAMAjAADAJgAAwCkAAMAsAADALwAAwDIAAMA1AADAOAAAwDoAAMB5AADAewAAwH0AAMB/AADAggAAwIMAAMCEAADAhQAAwIYAAMCIAADAigAAwIsAAMCMAADAjgAAwI8AAMDOAADA0AAAwNIAAMDUAADA1wAAwNgAAMDZAADA2gAAwNsAAMDdAADA3wAAwOAAAMDhAADA4wAAwOQAAMEjAADBJQAAwSgAAMEqAADBLQAAwS4AAMEvAADBMAAAwTEAAMEzAADBNQAAwTYAAME3AADBOQAAwToAAMFHAADBSAAAwUkAAMFLAADBigAAwYwAAMGOAADBkAAAwZMAAMGUAADBlQAAwZYAAMGXAADBmQAAwZsAAMGcAADBnQAAwZ8AAMGgAADB3wAAweEAAMHjAADB5QAAwegAAMHpAADB6gAAwesAAMHsAADB7gAAwfAAAMHxAADB8gAAwfQAAMH1AADCNAAAwjYAAMI4AADCOgAAwj0AAMI+AADCPwAAwkAAAMJBAADCQwAAwkUAAMJGAADCRwAAwkkAAMJKAADCiQAAwosAAMKNAADCjwAAwpIAAMKTAADClAAAwpUAAMKWAADCmAAAwpoAAMKbAADCnAAAwp4AAMKfAADC3gAAwuAAAMLiAADC5AAAwucAAMLoAADC6QAAwuoAAMLrAADC7QAAwu8AAMLwAADC8QAAwvMAAML0AADDGQAAwz0AAMNkAADDiAAAw4sAAMONAADDjwAAw5EAAMOTAADDlQAAw5YAAMOZAADDpgAAw7UAAMO3AADDuQAAw7sAAMO9AADDvwAAw8EAAMPDAADD0gAAw9UAAMPYAADD2wAAw94AAMPhAADD5AAAw+cAAMPpAADEKAAAxCoAAMQsAADELgAAxDEAAMQyAADEMwAAxDQAAMQ1AADENwAAxDkAAMQ6AADEOwAAxD0AAMQ+AADEfQAAxH8AAMSBAADEgwAAxIYAAMSHAADEiAAAxIkAAMSKAADEjAAAxI4AAMSPAADEkAAAxJIAAMSTAADE0gAAxNQAAMTWAADE2AAAxNsAAMTcAADE3QAAxN4AAMTfAADE4QAAxOMAAMTkAADE5QAAxOcAAMToAADFJwAAxSkAAMUsAADFLgAAxTEAAMUyAADFMwAAxTQAAMU1AADFNwAAxTkAAMU6AADFOwAAxT0AAMU+AADFgQAAxaUAAMXJAADF7AAAxhMAAMYzAADGWgAAxoEAAMahAADGxQAAxukAAMbrAADG7gAAxvAAAMbyAADG9AAAxvcAAMb6AADG/AAAxv4AAMcBAADHAwAAxwUAAMcIAADHCwAAxwwAAMcVAADHIgAAxyUAAMcnAADHKgAAxy0AAMcvAADHVAAAx3gAAMefAADHwwAAx8YAAMfIAADHygAAx8wAAMfOAADH0AAAx9EAAMfUAADH4QAAx/QAAMf2AADH+AAAx/oAAMf8AADH/gAAyAAAAMgCAADIBAAAyAYAAMgZAADIHAAAyB8AAMgiAADIJQAAyCgAAMgrAADILgAAyDEAAMg0AADINgAAyHUAAMh3AADIegAAyHwAAMh/AADIgAAAyIEAAMiCAADIgwAAyIUAAMiHAADIiAAAyIkAAMiLAADIjAAAyJUAAMiWAADImAAAyNcAAMjZAADI2wAAyN0AAMjgAADI4QAAyOIAAMjjAADI5AAAyOYAAMjoAADI6QAAyOoAAMjsAADI7QAAySwAAMkuAADJMQAAyTMAAMk2AADJNwAAyTgAAMk5AADJOgAAyTwAAMk+AADJPwAAyUAAAMlCAADJQwAAyUwAAMlNAADJTwAAyY4AAMmQAADJkgAAyZQAAMmXAADJmAAAyZkAAMmaAADJmwAAyZ0AAMmfAADJoAAAyaEAAMmjAADJpAAAyeMAAMnlAADJ6AAAyeoAAMntAADJ7gAAye8AAMnwAADJ8QAAyfMAAMn1AADJ9gAAyfcAAMn5AADJ+gAAygMAAMoEAADKBgAAykUAAMpHAADKSQAAyksAAMpOAADKTwAAylAAAMpRAADKUgAAylQAAMpWAADKVwAAylgAAMpaAADKWwAAypoAAMqcAADKnwAAyqEAAMqkAADKpQAAyqYAAMqnAADKqAAAyqoAAMqsAADKrQAAyq4AAMqwAADKsQAAyr4AAMq/AADKwAAAysIAAMsBAADLAwAAywUAAMsHAADLCgAAywsAAMsMAADLDQAAyw4AAMsQAADLEgAAyxMAAMsUAADLFgAAyxcAAMtWAADLWAAAy1oAAMtcAADLXwAAy2AAAMthAADLYgAAy2MAAMtlAADLZwAAy2gAAMtpAADLawAAy2wAAMt5AADLggAAy4UAAMuHAADLigAAy40AAMuWAADLmQAAy5wAAMufAADLogAAy6QAAMuzAADLxQAAy9IAAMwdAADMQAAAzGAAAMyAAADMggAAzIQAAMyGAADMiAAAzIsAAMyMAADMjQAAzJAAAMyRAADMkwAAzJQAAMyWAADMmQAAzJoAAMybAADMngAAzJ8AAMykAADMsQAAzLYAAMy4AADMugAAzL8AAMzCAADMxQAAzMcAAMzsAADNEAAAzTcAAM1bAADNXgAAzWAAAM1iAADNZAAAzWYAAM1oAADNaQAAzWwAAM15AADNigAAzYwAAM2OAADNkAAAzZIAAM2UAADNlgAAzZgAAM2aAADNqwAAza4AAM2xAADNtAAAzbcAAM26AADNvQAAzcAAAM3DAADNxQAAzgQAAM4GAADOCAAAzgoAAM4NAADODgAAzg8AAM4QAADOEQAAzhMAAM4VAADOFgAAzhcAAM4ZAADOGgAAzlkAAM5bAADOXQAAzl8AAM5iAADOYwAAzmQAAM5lAADOZgAAzmgAAM5qAADOawAAzmwAAM5uAADObwAAzq4AAM6wAADOswAAzrUAAM64AADOuQAAzroAAM67AADOvAAAzr4AAM7AAADOwQAAzsIAAM7EAADOxQAAztIAAM7TAADO1AAAztYAAM8VAADPFwAAzxkAAM8bAADPHgAAzx8AAM8gAADPIQAAzyIAAM8kAADPJgAAzycAAM8oAADPKgAAzysAAM9qAADPbAAAz24AAM9wAADPcwAAz3QAAM91AADPdgAAz3cAAM95AADPewAAz3wAAM99AADPfwAAz4AAAM+/AADPwQAAz8MAAM/FAADPyAAAz8kAAM/KAADPywAAz8wAAM/OAADP0AAAz9EAAM/SAADP1AAAz9UAANAUAADQFgAA0BgAANAaAADQHQAA0B4AANAfAADQIAAA0CEAANAjAADQJQAA0CYAANAnAADQKQAA0CoAANBpAADQawAA0G0AANBvAADQcgAA0HMAANB0AADQdQAA0HYAANB4AADQegAA0HsAANB8AADQfgAA0H8AANCkAADQyAAA0O8AANETAADRFgAA0RgAANEaAADRHAAA0R4AANEgAADRIQAA0SQAANExAADRQAAA0UIAANFEAADRRgAA0UgAANFKAADRTAAA0U4AANFdAADRYAAA0WMAANFmAADRaQAA0WwAANFvAADRcgAA0XQAANGzAADRtQAA0bcAANG5AADRvAAA0b0AANG+AADRvwAA0cAAANHCAADRxAAA0cUAANHGAADRyAAA0ckAANIIAADSCgAA0gwAANIOAADSEQAA0hIAANITAADSFAAA0hUAANIXAADSGQAA0hoAANIbAADSHQAA0h4AANJdAADSXwAA0mEAANJjAADSZgAA0mcAANJoAADSaQAA0moAANJsAADSbgAA0m8AANJwAADScgAA0nMAANKyAADStAAA0rYAANK4AADSuwAA0rwAANK9AADSvgAA0r8AANLBAADSwwAA0sQAANLFAADSxwAA0sgAANMHAADTCQAA0wsAANMNAADTEAAA0xEAANMSAADTEwAA0xQAANMWAADTGAAA0xkAANMaAADTHAAA0x0AANNcAADTXgAA02AAANNiAADTZQAA02YAANNnAADTaAAA02kAANNrAADTbQAA024AANNvAADTcQAA03IAANOxAADTswAA07YAANO4AADTuwAA07wAANO9AADTvgAA078AANPBAADTwwAA08QAANPFAADTxwAA08gAANQTAADUNgAA1FYAANR2AADUeAAA1HoAANR8AADUfgAA1IEAANSCAADUgwAA1IYAANSHAADUiQAA1IoAANSMAADUjgAA1I8AANSQAADUkwAA1JQAANSZAADUpgAA1KsAANStAADUrwAA1LQAANS3AADUugAA1LwAANThAADVBQAA1SwAANVQAADVUwAA1VUAANVXAADVWQAA1VsAANVdAADVXgAA1WEAANVuAADVfwAA1YEAANWDAADVhQAA1YcAANWJAADViwAA1Y0AANWPAADVoAAA1aMAANWmAADVqQAA1awAANWvAADVsgAA1bUAANW4AADVugAA1fkAANX7AADV/QAA1f8AANYCAADWAwAA1gQAANYFAADWBgAA1ggAANYKAADWCwAA1gwAANYOAADWDwAA1k4AANZQAADWUgAA1lQAANZXAADWWAAA1lkAANZaAADWWwAA1l0AANZfAADWYAAA1mEAANZjAADWZAAA1qMAANalAADWqAAA1qoAANatAADWrgAA1q8AANawAADWsQAA1rMAANa1AADWtgAA1rcAANa5AADWugAA1scAANbIAADWyQAA1ssAANcKAADXDAAA1w4AANcQAADXEwAA1xQAANcVAADXFgAA1xcAANcZAADXGwAA1xwAANcdAADXHwAA1yAAANdfAADXYQAA12MAANdlAADXaAAA12kAANdqAADXawAA12wAANduAADXcAAA13EAANdyAADXdAAA13UAANe0AADXtgAA17gAANe6AADXvQAA174AANe/AADXwAAA18EAANfDAADXxQAA18YAANfHAADXyQAA18oAANgJAADYCwAA2A0AANgPAADYEgAA2BMAANgUAADYFQAA2BYAANgYAADYGgAA2BsAANgcAADYHgAA2B8AANheAADYYAAA2GIAANhkAADYZwAA2GgAANhpAADYagAA2GsAANhtAADYbwAA2HAAANhxAADYcwAA2HQAANiZAADYvQAA2OQAANkIAADZCwAA2Q0AANkPAADZEQAA2RMAANkVAADZFgAA2RkAANkmAADZNQAA2TcAANk5AADZOwAA2T0AANk/AADZQQAA2UMAANlSAADZVQAA2VgAANlbAADZXgAA2WEAANlkAADZZwAA2WkAANmoAADZqgAA2awAANmuAADZsQAA2bIAANmzAADZtAAA2bUAANm3AADZuQAA2boAANm7AADZvQAA2b4AANn9AADZ/wAA2gEAANoDAADaBgAA2gcAANoIAADaCQAA2goAANoMAADaDgAA2g8AANoQAADaEgAA2hMAANpSAADaVAAA2lYAANpYAADaWwAA2lwAANpdAADaXgAA2l8AANphAADaYwAA2mQAANplAADaZwAA2mgAANqnAADaqQAA2qsAANqtAADasAAA2rEAANqyAADaswAA2rQAANq2AADauAAA2rkAANq6AADavAAA2r0AANr8AADa/gAA2wAAANsCAADbBQAA2wYAANsHAADbCAAA2wkAANsLAADbDQAA2w4AANsPAADbEQAA2xIAANtRAADbUwAA21UAANtXAADbWgAA21sAANtcAADbXQAA214AANtgAADbYgAA22MAANtkAADbZgAA22cAANumAADbqAAA26sAANutAADbsAAA27EAANuyAADbswAA27QAANu2AADbuAAA27kAANu6AADbvAAA270AANwIAADcKwAA3EsAANxrAADcbQAA3G8AANxxAADccwAA3HUAANx2AADcdwAA3HoAANx7AADcfQAA3H4AANyAAADcggAA3IMAANyEAADchwAA3IgAANyNAADcmgAA3J8AANyhAADcowAA3KgAANyrAADcrgAA3LAAANzVAADc+QAA3SAAAN1EAADdRwAA3UkAAN1LAADdTQAA3U8AAN1RAADdUgAA3VUAAN1iAADdcwAA3XUAAN13AADdeQAA3XsAAN19AADdfwAA3YEAAN2DAADdlAAA3ZcAAN2aAADdnQAA3aAAAN2jAADdpgAA3akAAN2sAADdrgAA3e0AAN3vAADd8QAA3fMAAN32AADd9wAA3fgAAN35AADd+gAA3fwAAN3+AADd/wAA3gAAAN4CAADeAwAA3kIAAN5EAADeRgAA3kgAAN5LAADeTAAA3k0AAN5OAADeTwAA3lEAAN5TAADeVAAA3lUAAN5XAADeWAAA3pcAAN6ZAADenAAA3p4AAN6hAADeogAA3qMAAN6kAADepQAA3qcAAN6pAADeqgAA3qsAAN6tAADergAA3rsAAN68AADevQAA3r8AAN7+AADfAAAA3wIAAN8EAADfBwAA3wgAAN8JAADfCgAA3wsAAN8NAADfDwAA3xAAAN8RAADfEwAA3xQAAN9TAADfVQAA31cAAN9ZAADfXAAA310AAN9eAADfXwAA32AAAN9iAADfZAAA32UAAN9mAADfaAAA32kAAN+oAADfqgAA36wAAN+uAADfsQAA37IAAN+zAADftAAA37UAAN+3AADfuQAA37oAAN+7AADfvQAA374AAN/9AADf/wAA4AEAAOADAADgBgAA4AcAAOAIAADgCQAA4AoAAOAMAADgDgAA4A8AAOAQAADgEgAA4BMAAOBSAADgVAAA4FYAAOBYAADgWwAA4FwAAOBdAADgXgAA4F8AAOBhAADgYwAA4GQAAOBlAADgZwAA4GgAAOCNAADgsQAA4NgAAOD8AADg/wAA4QEAAOEDAADhBQAA4QcAAOEJAADhCgAA4Q0AAOEaAADhKQAA4SsAAOEtAADhLwAA4TEAAOEzAADhNQAA4TcAAOFGAADhSQAA4UwAAOFPAADhUgAA4VUAAOFYAADhWwAA4V0AAOGcAADhngAA4aAAAOGiAADhpQAA4aY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AAQAdAAEAIAABACEAAQAiAAEAIwABACQAAQAmAAEAKAABACkAAQAqAAEALAABAC0AAQBsAAEAbgABAHAAAQByAAEAdQABAHYAAQB3AAEAeAABAHkAAQB7AAEAfQABAH4AAQB/AAEAgQABAIIAAQDBAAEAwwABAMUAAQDHAAEAygABAMsAAQDMAAEAzQABAM4AAQDQAAEA0gABANMAAQDUAAEA1gABANcAAQEWAAEBGAABARoAAQEcAAEBHwABASAAAQEhAAEBIgABASMAAQElAAEBJwABASgAAQEpAAEBKwABASwAAQFRAAEBdQABAZwAAQHAAAEBwwABAcUAAQHHAAEByQABAcsAAQHNAAEBzgABAdEAAQHeAAEB7QABAe8AAQHxAAEB8wABAfUAAQH3AAEB+QABAfsAAQIKAAECDQABAhAAAQITAAECFgABAhkAAQIcAAECHwABAiEAAQJgAAECYgABAmQAAQJmAAECaQABAmoAAQJrAAECbAABAm0AAQJvAAECcQABAnIAAQJzAAECdQABAnYAAQK1AAECtwABArkAAQK7AAECvgABAr8AAQLAAAECwQABAsIAAQLEAAECxgABAscAAQLIAAECygABAssAAQMKAAEDDAABAw4AAQMQAAEDEwABAxQAAQMVAAEDFgABAxcAAQMZAAEDGwABAxwAAQMdAAEDHwABAyAAAQNfAAEDYQABA2MAAQNlAAEDaAABA2kAAQNqAAEDawABA2wAAQNuAAEDcAABA3EAAQNyAAEDdAABA3UAAQO0AAEDtgABA7gAAQO6AAEDvQABA74AAQO/AAEDwAABA8EAAQPDAAEDxQABA8YAAQPHAAEDyQABA8oAAQQJAAEECwABBA0AAQQPAAEEEgABBBMAAQQUAAEEFQABBBYAAQQYAAEEGgABBBsAAQQcAAEEHgABBB8AAQReAAEEYAABBGIAAQRkAAEEZwABBGgAAQRpAAEEagABBGsAAQRtAAEEbwABBHAAAQRxAAEEcwABBHQAAQS/AAEE4gABBQIAAQUiAAEFJAABBSYAAQUoAAEFKgABBS0AAQUuAAEFLwABBTIAAQUzAAEFNQABBTYAAQU4AAEFOwABBTwAAQU9AAEFQAABBUEAAQVGAAEFUwABBVgAAQVaAAEFXAABBWEAAQVkAAEFZwABBWkAAQWOAAEFsgABBdk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AARC7AAEQvAABEL0AARC/AAEQwQABEMIAARDDAAEQxQABEMYAARDcAAERGwABER0AAREfAAERIQABESQAARElAAERJgABEScAAREoAAERKgABESwAAREtAAERLgABETAAARExAAERVgABEXoAARGhAAERxQABEcgAARHKAAERzAABEc4AARHQAAER0gABEdMAARHWAAER4wABEfIAARH0AAER9gABEfgAARH6AAER/AABEf4AARIAAAESDwABEhIAARIVAAESGAABEhsAARIeAAESIQABEiQAARImAAESZQABEmcAARJpAAESawABEm4AARJvAAEScAABEnEAARJyAAESdAABEnYAARJ3AAESeAABEnoAARJ7AAESugABErwAARK+AAESwAABEsMAARLEAAESxQABEsYAARLHAAESyQABEssAARLMAAESzQABEs8AARLQAAETDwABExEAARMTAAETFQABExgAARMZAAETGgABExsAARMcAAETHgABEyAAARMhAAETIgABEyQAARMlAAETZAABE2YAARNoAAETagABE20AARNuAAETbwABE3AAARNxAAETcwABE3UAARN2AAETdwABE3kAARN6AAETuQABE7sAARO9AAETvwABE8IAARPDAAETxAABE8UAARPGAAETyAABE8oAARPLAAETzAABE84AARPPAAEUDgABFBAAARQSAAEUFAABFBcAARQYAAEUGQABFBoAARQbAAEUHQABFB8AARQgAAEUIQABFCMAARQkAAEUYwABFGUAARRnAAEUaQABFGwAARRtAAEUbgABFG8AARRwAAEUcgABFHQAARR1AAEUdgABFHgAARR5AAEUxAABFOcAARUHAAEVJwABFSkAARUrAAEVLQABFS8AARUyAAEVMwABFTQAARU3AAEVOAABFToAARU7AAEVPQABFUAAARVBAAEVQgABFUUAARVGAAEVSwABFVgAARVdAAEVXwABFWEAARVmAAEVaQABFWwAARVuAAEVkwABFbcAARXeAAEWAgABFgUAARYHAAEWCQABFgsAARYNAAEWDwABFhAAARYTAAEWIAABFjEAARYzAAEWNQABFjcAARY5AAEWOwABFj0AARY/AAEWQQABFlIAARZVAAEWWAABFlsAARZeAAEWYQABFmQAARZnAAEWagABFmwAARarAAEWrQABFq8AARaxAAEWtAABFrUAARa2AAEWtwABFrgAARa6AAEWvAABFr0AARa+AAEWwAABFsEAARcAAAEXAgABFwQAARcGAAEXCQABFwoAARcLAAEXDAABFw0AARcPAAEXEQABFxIAARcTAAEXFQABFxYAARdVAAEXVwABF1oAARdcAAEXXwABF2AAARdhAAEXYgABF2MAARdlAAEXZwABF2gAARdpAAEXawABF2wAARd5AAEXegABF3sAARd9AAEXvAABF74AARfAAAEXwgABF8UAARfGAAEXxwABF8gAARfJAAEXywABF80AARfOAAEXzwABF9EAARfSAAEYEQABGBMAARgVAAEYFwABGBoAARgbAAEYHAABGB0AARgeAAEYIAABGCIAARgjAAEYJAABGCYAARgnAAEYZgABGGgAARhqAAEYbAABGG8AARhwAAEYcQABGHIAARhzAAEYdQABGHcAARh4AAEYeQABGHsAARh8AAEYuwABGL0AARi/AAEYwQABGMQAARjFAAEYxgABGMcAARjIAAEYygABGMwAARjNAAEYzgABGNAAARjRAAEZEAABGRIAARkUAAEZFgABGRkAARkaAAEZGwABGRwAARkdAAEZHwABGSEAARkiAAEZIwABGSUAARkmAAEZSwABGW8AARmWAAEZugABGb0AARm/AAEZwQABGcMAARnFAAEZxwABGcgAARnLAAEZ2AABGecAARnpAAEZ6wABGe0AARnvAAEZ8QABGfMAARn1AAEaBAABGgcAARoKAAEaDQABGhAAARoTAAEaFgABGhkAARobAAEaWgABGlwAARpeAAEaYAABGmMAARpkAAEaZQABGmYAARpnAAEaaQABGmsAARpsAAEabQABGm8AARpwAAEarwABGrEAARqzAAEatQABGrgAARq5AAEaugABGrsAARq8AAEavgABGsAAARrBAAEawgABGsQAARrFAAEbBAABGwYAARsIAAEbCgABGw0AARsOAAEbDwABGxAAARsRAAEbEwABGxUAARsWAAEbFwABGxkAARsaAAEbWQABG1sAARtdAAEbXwABG2IAARtjAAEbZAABG2UAARtmAAEbaAABG2oAARtrAAEbbAABG24AARtvAAEbrgABG7AAARuyAAEbtAABG7cAARu4AAEbuQABG7oAARu7AAEbvQABG78AARvAAAEbwQABG8MAARvEAAEcAwABHAUAARwHAAEcCQABHAwAARwNAAEcDgABHA8AARwQAAEcEgABHBQAARwVAAEcFgABHBgAARwZAAEcWAABHFoAARxcAAEcXgABHGEAARxiAAEcYwABHGQAARxlAAEcZwABHGkAARxqAAEcawABHG0AARxuAAEcuQABHNwAARz8AAEdHAABHR4AAR0gAAEdIgABHSQAAR0nAAEdKAABHSkAAR0sAAEdLQABHS8AAR0wAAEdMgABHTUAAR02AAEdNwABHToAAR07AAEdQAABHU0AAR1SAAEdVAABHVYAAR1bAAEdXgABHWEAAR1jAAEdiAABHawAAR3TAAEd9wABHfoAAR38AAEd/gABHgAAAR4CAAEeBAABHgUAAR4IAAEeFQABHiYAAR4oAAEeKgABHiwAAR4uAAEeMAABHjIAAR40AAEeNgABHkcAAR5KAAEeTQABHlAAAR5TAAEeVgABHlkAAR5cAAEeXwABHmEAAR6gAAEeogABHqQAAR6mAAEeqQABHqoAAR6rAAEerAABHq0AAR6vAAEesQABHrIAAR6zAAEetQABHrYAAR71AAEe9wABHvkAAR77AAEe/gABHv8AAR8AAAEfAQABHwIAAR8EAAEfBgABHwcAAR8IAAEfCgABHwsAAR9KAAEfTAABH08AAR9RAAEfVAABH1UAAR9WAAEfVwABH1gAAR9aAAEfXAABH10AAR9eAAEfYAABH2EAAR9uAAEfbwABH3AAAR9yAAEfsQABH7MAAR+1AAEftwABH7oAAR+7AAEfvAABH70AAR++AAEfwAABH8IAAR/DAAEfxAABH8YAAR/HAAEgBgABIAgAASAKAAEgDAABIA8AASAQAAEgEQABIBIAASATAAEgFQABIBcAASAYAAEgGQABIBsAASAcAAEgWwABIF0AASBfAAEgYQABIGQAASBlAAEgZgABIGcAASBoAAEgagABIGwAASBtAAEgbgABIHAAASBxAAEgsAABILIAASC0AAEgtgABILkAASC6AAEguwABILwAASC9AAEgvwABIMEAASDCAAEgwwABIMUAASDGAAEhBQABIQcAASEJAAEhCwABIQ4AASEPAAEhEAABIREAASESAAEhFAABIRYAASEXAAEhGAABIRoAASEbAAEhQAABIWQAASGLAAEhrwABIbIAASG0AAEhtgABIbgAASG6AAEhvAABIb0AASHAAAEhzQABIdwAASHeAAEh4AABIeIAASHkAAEh5gABIegAASHqAAEh+QABIfwAASH/AAEiAgABIgUAASIIAAEiCwABIg4AASIQAAEiTwABIlEAASJTAAEiVQABIlgAASJZAAEiWgABIlsAASJcAAEiXgABImAAASJhAAEiYgABImQAASJlAAEipAABIqYAASKoAAEiqgABIq0AASKuAAEirwABIrAAASKxAAEiswABIrUAASK2AAEitwABIrkAASK6AAEi+QABIvsAASL9AAEi/wABIwIAASMDAAEjBAABIwUAASMGAAEjCAABIwoAASMLAAEjDAABIw4AASMPAAEjTgABI1AAASNSAAEjVAABI1cAASNYAAEjWQABI1oAASNbAAEjXQABI18AASNgAAEjYQABI2MAASNkAAEjowABI6UAASOnAAEjqQABI6wAASOtAAEjrgABI68AASOwAAEjsgABI7QAASO1AAEjtgABI7gAASO5AAEj+AABI/oAASP8AAEj/gABJAEAASQCAAEkAwABJAQAASQFAAEkBwABJAkAASQKAAEkCwABJA0AASQOAAEkTQABJE8AASRRAAEkUwABJFYAASRXAAEkWAABJFkAASRaAAEkXAABJF4AASRfAAEkYAABJGIAASRjAAEkrgABJNEAASTxAAElEQABJRMAASUVAAElFwABJRkAASUcAAElHQABJR4AASUhAAElIgABJSQAASUlAAElJwABJSoAASUrAAElLAABJS8AASUwAAElNQABJUIAASVHAAElSQABJUsAASVQAAElUwABJVYAASVYAAElfQABJaEAASXIAAEl7AABJe8AASXxAAEl8wABJfUAASX3AAEl+QABJfoAASX9AAEmCgABJhsAASYdAAEmHwABJiEAASYjAAEmJQABJicAASYpAAEmKwABJjwAASY/AAEmQgABJkUAASZIAAEmSwABJk4AASZRAAEmVAABJlYAASaVAAEmlwABJpkAASabAAEmngABJp8AASagAAEmoQABJqIAASakAAEmpgABJqcAASaoAAEmqgABJqsAASbqAAEm7AABJu4AASbwAAEm8wABJvQAASb1AAEm9gABJvcAASb5AAEm+wABJvwAASb9AAEm/wABJwAAASc/AAEnQQABJ0QAASdGAAEnSQABJ0oAASdLAAEnTAABJ00AASdPAAEnUQABJ1IAASdTAAEnVQABJ1YAASdjAAEnZAABJ2UAASdnAAEnpgABJ6gAASeqAAEnrAABJ68AASewAAEnsQABJ7IAASezAAEntQABJ7cAASe4AAEnuQABJ7sAASe8AAEn+wABJ/0AASf/AAEoAQABKAQAASgFAAEoBgABKAcAASgIAAEoCgABKAwAASgNAAEoDgABKBAAASgRAAEoUAABKFIAAShUAAEoVgABKFkAAShaAAEoWwABKFwAAShdAAEoXwABKGEAAShiAAEoYwABKGUAAShmAAEopQABKKcAASipAAEoqwABKK4AASivAAEosAABKLEAASiyAAEotAABKLYAASi3AAEouAABKLoAASi7AAEo+gABKPwAASj+AAEpAAABKQMAASkEAAEpBQABKQYAASkHAAEpCQABKQsAASkMAAEpDQABKQ8AASkQAAEpNQABKVkAASmAAAEppAABKacAASmpAAEpqwABKa0AASmvAAEpsQABKbIAASm1AAEpwgABKdEAASnTAAEp1QABKdcAASnZAAEp2wABKd0AASnfAAEp7gABKfEAASn0AAEp9wABKfoAASn9AAEqAAABKgMAASoFAAEqRAABKkYAASpIAAEqSgABKk0AASpOAAEqTwABKlAAASpRAAEqUwABKlUAASpWAAEqVwABKlkAASpaAAEqmQABKpsAASqdAAEqnwABKqIAASqjAAEqpAABKqUAASqmAAEqqAABKqoAASqrAAEqrAABKq4AASqvAAEq7gABKvAAASryAAEq9AABKvcAASr4AAEq+QABKvoAASr7AAEq/QABKv8AASsAAAErAQABKwMAASsEAAErQwABK0UAAStHAAErSQABK0wAAStNAAErTgABK08AAStQAAErUgABK1QAAStVAAErVgABK1gAAStZAAErmAABK5oAASucAAErngABK6EAASuiAAErowABK6QAASulAAErpwABK6kAASuqAAErqwABK60AASuuAAEr7QABK+8AASvxAAEr8wABK/YAASv3AAEr+AABK/kAASv6AAEr/AABK/4AASv/AAEsAAABLAIAASwDAAEsQgABLEQAASxGAAEsSAABLEsAASxMAAEsTQABLE4AASxPAAEsUQABLFMAASxUAAEsVQABLFcAASxYAAEsowABLMYAASzmAAEtBgABLQgAAS0KAAEtDAABLQ4AAS0RAAEtEgABLRMAAS0WAAEtFwABLRkAAS0aAAEtHAABLR8AAS0gAAEtIQABLSQAAS0lAAEtKgABLTcAAS08AAEtPgABLUAAAS1FAAEtSAABLUsAAS1NAAEtcgABLZYAAS29AAEt4QABLeQAAS3mAAEt6AABLeoAAS3sAAEt7gABLe8AAS3yAAEt/wABLhAAAS4SAAEuFAABLhYAAS4YAAEuGgABLhwAAS4eAAEuIAABLjEAAS40AAEuNwABLjoAAS49AAEuQAABLkMAAS5GAAEuSQABLksAAS6KAAEujAABLo4AAS6QAAEukwABLpQAAS6VAAEulgABLpcAAS6ZAAEumwABLpwAAS6dAAEunwABLqAAAS7fAAEu4QABLuMAAS7lAAEu6AABLukAAS7qAAEu6wABLuwAAS7uAAEu8AABLvEAAS7yAAEu9AABLvUAAS80AAEvNgABLzkAAS87AAEvPgABLz8AAS9AAAEvQQABL0IAAS9EAAEvRgABL0cAAS9IAAEvSgABL0sAAS9YAAEvWQABL1oAAS9cAAEvmwABL50AAS+fAAEvoQABL6QAAS+lAAEvpgABL6cAAS+oAAEvqgABL6wAAS+tAAEvrgABL7AAAS+xAAEv8AABL/IAAS/0AAEv9gABL/kAAS/6AAEv+wABL/wAAS/9AAEv/wABMAEAATACAAEwAwABMAUAATAGAAEwRQABMEcAATBJAAEwSwABME4AATBPAAEwUAABMFEAATBSAAEwVAABMFYAATBXAAEwWAABMFoAATBbAAEwmgABMJwAATCeAAEwoAABMKMAATCkAAEwpQABMKYAATCnAAEwqQABMKsAATCsAAEwrQABMK8AATCwAAEw7wABMPEAATDzAAEw9QABMPgAATD5AAEw+gABMPsAATD8AAEw/gABMQAAATEBAAExAgABMQQAATEFAAExKgABMU4AATF1AAExmQABMZwAATGeAAExoAABMaIAATGkAAExpgABMacAATGqAAExtwABMcYAATHIAAExygABMcwAATHOAAEx0AABMdIAATHUAAEx4wABMeYAATHpAAEx7AABMe8AATHyAAEx9QABMfgAATH6AAEyOQABMjsAATI9AAEyPwABMkIAATJDAAEyRAABMkUAATJGAAEySAABMkoAATJLAAEyTAABMk4AATJPAAEyjgABMpAAATKSAAEylAABMpcAATKYAAEymQABMpoAATKbAAEynQABMp8AATKgAAEyoQABMqMAATKkAAEy4wABMuUAATLnAAEy6QABMuwAATLtAAEy7gABMu8AATLwAAEy8gABMvQAATL1AAEy9gABMvgAATL5AAEzOAABMzoAATM8AAEzPgABM0EAATNCAAEzQwABM0QAATNFAAEzRwABM0kAATNKAAEzSwABM00AATNOAAEzjQABM48AATORAAEzkwABM5YAATOXAAEzmAABM5kAATOaAAEznAABM54AATOfAAEzoAABM6IAATOjAAEz4gABM+QAATPmAAEz6AABM+sAATPsAAEz7QABM+4AATPvAAEz8QABM/MAATP0AAEz9QABM/cAATP4AAE0NwABNDkAATQ7AAE0PQABNEAAATRBAAE0QgABNEMAATREAAE0RgABNEgAATRJAAE0SgABNEwAATRNAAE0mAABNLsAATTbAAE0+wABNP0AATT/AAE1AQABNQMAATUGAAE1BwABNQgAATULAAE1DAABNQ4AATUPAAE1EQABNRQAATUVAAE1FgABNRkAATUaAAE1HwABNSwAATUxAAE1MwABNTUAATU6AAE1PQABNUAAATVCAAE1ZwABNYsAATWyAAE11gABNdkAATXbAAE13QABNd8AATXhAAE14wABNeQAATXnAAE19AABNgUAATYHAAE2CQABNgsAATYNAAE2DwABNhEAATYTAAE2FQABNiYAATYpAAE2LAABNi8AATYyAAE2NQABNjgAATY7AAE2PgABNkAAATZ/AAE2gQABNoMAATaFAAE2iAABNokAATaKAAE2iwABNowAATaOAAE2kAABNpEAATaSAAE2lAABNpUAATbUAAE21gABNtgAATbaAAE23QABNt4AATbfAAE24AABNuEAATbjAAE25QABNuYAATbnAAE26QABNuoAATcpAAE3KwABNy4AATcwAAE3MwABNzQAATc1AAE3NgABNzcAATc5AAE3OwABNzwAATc9AAE3PwABN0AAATdNAAE3TgABN08AATdRAAE3kAABN5IAATeUAAE3lgABN5kAATeaAAE3mwABN5wAATedAAE3nwABN6EAATeiAAE3owABN6UAATemAAE35QABN+cAATfpAAE36wABN+4AATfvAAE38AABN/EAATfyAAE39AABN/YAATf3AAE3+AABN/oAATf7AAE4OgABODwAATg+AAE4QAABOEMAAThEAAE4RQABOEYAAThHAAE4SQABOEsAAThMAAE4TQABOE8AAThQAAE4jwABOJEAATiTAAE4lQABOJgAATiZAAE4mgABOJsAATicAAE4ngABOKAAATihAAE4ogABOKQAATilAAE45AABOOYAATjoAAE46gABOO0AATjuAAE47wABOPAAATjxAAE48wABOPUAATj2AAE49wABOPkAATj6AAE5HwABOUMAATlqAAE5jgABOZEAATmTAAE5lQABOZcAATmZAAE5mwABOZwAATmfAAE5rAABObsAATm9AAE5vwABOcEAATnDAAE5xQABOccAATnJAAE52AABOdsAATneAAE54QABOeQAATnnAAE56gABOe0AATnvAAE6LgABOjAAAToyAAE6NAABOjcAATo4AAE6OQABOjoAATo7AAE6PQABOj8AATpAAAE6QQABOkMAATpEAAE6gwABOoUAATqHAAE6iQABOowAATqNAAE6jgABOo8AATqQAAE6kgABOpQAATqVAAE6lgABOpgAATqZAAE62AABOtoAATrcAAE63gABOuEAATriAAE64wABOuQAATrlAAE65wABOukAATrqAAE66wABOu0AATruAAE7LQABOy8AATsxAAE7MwABOzYAATs3AAE7OAABOzkAATs6AAE7PAABOz4AATs/AAE7QAABO0IAATtDAAE7ggABO4QAATuGAAE7iAABO4sAATuMAAE7jQABO44AATuPAAE7kQABO5MAATuUAAE7lQABO5cAATuYAAE71wABO9kAATvbAAE73QABO+AAATvhAAE74gABO+MAATvkAAE75gABO+gAATvpAAE76gABO+wAATvtAAE8LAABPC4AATwwAAE8MgABPDUAATw2AAE8NwABPDgAATw5AAE8OwABPD0AATw+AAE8PwABPEEAATxCAAE8jQABPLAAATzQAAE88AABPPIAATz0AAE89gABPPgAATz7AAE8/AABPP0AAT0AAAE9AQABPQMAAT0EAAE9BgABPQkAAT0KAAE9CwABPQ4AAT0PAAE9FAABPSEAAT0mAAE9KAABPSoAAT0vAAE9MgABPTUAAT03AAE9XAABPYAAAT2nAAE9ywABPc4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AAU6IAAFOiQABTooAAU6LAAFOjQABTo8AAU6QAAFOkQABTpMAAU6UAAFO0wABTtUAAU7XAAFO2QABTtwAAU7dAAFO3gABTt8AAU7gAAFO4gABTuQAAU7lAAFO5gABTugAAU7pAAFPKAABTyoAAU8tAAFPLwABTzIAAU8zAAFPNAABTzUAAU82AAFPOAABTzoAAU87AAFPPAABTz4AAU8/AAFPTAABT00AAU9OAAFPUAABT48AAU+RAAFPkwABT5UAAU+YAAFPmQABT5oAAU+bAAFPnAABT54AAU+gAAFPoQABT6IAAU+kAAFPpQABT+QAAU/mAAFP6AABT+oAAU/tAAFP7gABT+8AAU/wAAFP8QABT/MAAU/1AAFP9gABT/cAAU/5AAFP+gABUDkAAVA7AAFQPQABUD8AAVBCAAFQQwABUEQAAVBFAAFQRgABUEgAAVBKAAFQSwABUEwAAVBOAAFQTwABUI4AAVCQAAFQkgABUJQAAVCXAAFQmAABUJkAAVCaAAFQmwABUJ0AAVCfAAFQoAABUKEAAVCjAAFQpAABUOMAAVDlAAFQ5wABUOkAAVDsAAFQ7QABUO4AAVDvAAFQ8AABUPIAAVD0AAFQ9QABUPYAAVD4AAFQ+QABUR4AAVFCAAFRaQABUY0AAVGQAAFRkgABUZQAAVGWAAFRmAABUZoAAVGbAAFRngABUasAAVG6AAFRvAABUb4AAVHAAAFRwgABUcQAAVHGAAFRyAABUdcAAVHaAAFR3QABUeAAAVHjAAFR5gABUekAAVHsAAFR7gABUi0AAVIvAAFSMQABUjMAAVI2AAFSNwABUjgAAVI5AAFSOgABUjwAAVI+AAFSPwABUkAAAVJCAAFSQwABUoIAAVKEAAFShgABUogAAVKLAAFSjAABUo0AAVKOAAFSjwABUpEAAVKTAAFSlAABUpUAAVKXAAFSmAABUtcAAVLZAAFS2wABUt0AAVLgAAFS4QABUuIAAVLjAAFS5AABUuYAAVLoAAFS6QABUuoAAVLsAAFS7QABUywAAVMuAAFTMAABUzIAAVM1AAFTNgABUzcAAVM4AAFTOQABUzsAAVM9AAFTPgABUz8AAVNBAAFTQgABU4EAAVODAAFThQABU4cAAVOKAAFTiwABU4wAAVONAAFTjgABU5AAAVOSAAFTkwABU5QAAVOWAAFTlwABU9YAAVPYAAFT2gABU9wAAVPfAAFT4AABU+EAAVPiAAFT4wABU+UAAVPnAAFT6AABU+kAAVPrAAFT7AABVCsAAVQtAAFULwABVDEAAVQ0AAFUNQABVDYAAVQ3AAFUOAABVDoAAVQ8AAFUPQABVD4AAVRAAAFUQQABVIwAAVSvAAFUzwABVO8AAVTxAAFU8wABVPUAAVT3AAFU+gABVPsAAVT8AAFU/wABVQAAAVUCAAFVAwABVQUAAVUIAAFVCQABVQoAAVUNAAFVDgABVRcAAVUkAAFVKQABVSsAAVUtAAFVMgABVTUAAVU4AAFVOgABVV8AAVWDAAFVqgABVc4AAVXRAAFV0wABVdUAAVXXAAFV2QABVdsAAVXcAAFV3wABVewAAVX9AAFV/wABVgEAAVYDAAFWBQABVgcAAVYJAAFWCwABVg0AAVYeAAFWIQABViQAAVYnAAFWKgABVi0AAVYwAAFWMwABVjYAAVY4AAFWdwABVnkAAVZ7AAFWfQABVoAAAVaBAAFWggABVoMAAVaEAAFWhgABVogAAVaJAAFWigABVowAAVaNAAFWzAABVs4AAVbQAAFW0gABVtUAAVbWAAFW1wABVtgAAVbZAAFW2wABVt0AAVbeAAFW3wABVuEAAVbiAAFXIQABVyMAAVcmAAFXKAABVysAAVcsAAFXLQABVy4AAVcvAAFXMQABVzMAAVc0AAFXNQABVzcAAVc4AAFXRQABV0YAAVdHAAFXSQABV4gAAVeKAAFXjAABV44AAVeRAAFXkgABV5MAAVeUAAFXlQABV5cAAVeZAAFXmgABV5sAAVedAAFXngABV90AAVffAAFX4QABV+MAAVfmAAFX5wABV+gAAVfpAAFX6gABV+wAAVfuAAFX7wABV/AAAVfyAAFX8wABWDIAAVg0AAFYNgABWDgAAVg7AAFYPAABWD0AAVg+AAFYPwABWEEAAVhDAAFYRAABWEUAAVhHAAFYSAABWIcAAViJAAFYiwABWI0AAViQAAFYkQABWJIAAViTAAFYlAABWJYAAViYAAFYmQABWJoAAVicAAFYnQABWNwAAVjeAAFY4AABWOIAAVjlAAFY5gABWOcAAVjoAAFY6QABWOsAAVjtAAFY7gABWO8AAVjxAAFY8gABWRcAAVk7AAFZYgABWYYAAVmJAAFZiwABWY0AAVmPAAFZkQABWZMAAVmUAAFZlwABWaQAAVmzAAFZtQABWbcAAVm5AAFZuwABWb0AAVm/AAFZwQABWdAAAVnTAAFZ1gABWdkAAVncAAFZ3wABWeIAAVnlAAFZ5wABWiYAAVooAAFaKgABWiwAAVovAAFaMAABWjEAAVoyAAFaMwABWjUAAVo3AAFaOAABWjkAAVo7AAFaPAABWnsAAVp9AAFafwABWoEAAVqEAAFahQABWoYAAVqHAAFaiAABWooAAVqMAAFajQABWo4AAVqQAAFakQABWtAAAVrSAAFa1AABWtYAAVrZAAFa2gABWtsAAVrcAAFa3QABWt8AAVrhAAFa4gABWuMAAVrlAAFa5gABWyUAAVsnAAFbKQABWysAAVsuAAFbLwABWzAAAVsxAAFbMgABWzQAAVs2AAFbNwABWzgAAVs6AAFbOwABW3oAAVt8AAFbfgABW4AAAVuDAAFbhAABW4UAAVuGAAFbhwABW4kAAVuLAAFbjAABW40AAVuPAAFbkAABW88AAVvRAAFb0wABW9UAAVvYAAFb2QABW9oAAVvbAAFb3AABW94AAVvgAAFb4QABW+IAAVvkAAFb5QABXCQAAVwmAAFcKAABXCoAAVwtAAFcLgABXC8AAVwwAAFcMQABXDMAAVw1AAFcNgABXDcAAVw5AAFcOgABXIUAAVyoAAFcyAABXOgAAVzqAAFc7AABXO4AAVzwAAFc8wABXPQAAVz1AAFc+AABXPkAAVz7AAFc/AABXP4AAV0AAAFdAQABXQIAAV0FAAFdBgABXQsAAV0YAAFdHQABXR8AAV0hAAFdJgABXSkAAV0sAAFdLgABXVMAAV13AAFdngABXcIAAV3FAAFdxwABXckAAV3LAAFdzQABXc8AAV3QAAFd0wABXeAAAV3xAAFd8wABXfUAAV33AAFd+QABXfsAAV39AAFd/wABXgEAAV4SAAFeFQABXhgAAV4bAAFeHgABXiEAAV4kAAFeJwABXioAAV4sAAFeawABXm0AAV5vAAFecQABXnQAAV51AAFedgABXncAAV54AAFeegABXnwAAV59AAFefgABXoAAAV6BAAFewAABXsIAAV7EAAFexgABXskAAV7KAAFeywABXswAAV7NAAFezwABXtEAAV7SAAFe0wABXtUAAV7WAAFfFQABXxcAAV8aAAFfHAABXx8AAV8gAAFfIQABXyIAAV8jAAFfJQABXycAAV8oAAFfKQABXysAAV8sAAFfOQABXzoAAV87AAFfPQABX3wAAV9+AAFfgAABX4IAAV+FAAFfhgABX4cAAV+IAAFfiQABX4sAAV+NAAFfjgABX48AAV+RAAFfkgABX9EAAV/TAAFf1QABX9cAAV/aAAFf2wABX9wAAV/dAAFf3gABX+AAAV/iAAFf4wABX+QAAV/mAAFf5wABYCYAAWAoAAFgKgABYCwAAWAvAAFgMAABYDEAAWAyAAFgMwABYDUAAWA3AAFgOAABYDkAAWA7AAFgPAABYHsAAWB9AAFgfwABYIEAAWCEAAFghQABYIYAAWCHAAFgiAABYIoAAWCMAAFgjQABYI4AAWCQAAFgkQABYNAAAWDSAAFg1AABYNYAAWDZAAFg2gABYNsAAWDcAAFg3QABYN8AAWDhAAFg4gABYOMAAWDlAAFg5gABYQsAAWEvAAFhVgABYXoAAWF9AAFhfwABYYEAAWGDAAFhhQABYYcAAWGIAAFhiwABYZgAAWGnAAFhqQABYasAAWGtAAFhrwABYbEAAWGzAAFhtQABYcQAAWHHAAFhygABYc0AAWHQAAFh0wABYdYAAWHZAAFh2wABYhoAAWIcAAFiHgABYiAAAWIjAAFiJAABYiUAAWImAAFiJwABYikAAWIrAAFiLAABYi0AAWIvAAFiMAABYm8AAWJxAAFicwABYnUAAWJ4AAFieQABYnoAAWJ7AAFifAABYn4AAWKAAAFigQABYoIAAWKEAAFihQABYsQAAWLGAAFiyAABYsoAAWLNAAFizgABYs8AAWLQAAFi0QABYtMAAWLVAAFi1gABYtcAAWLZAAFi2gABYxkAAWMbAAFjHQABYx8AAWMiAAFjIwABYyQAAWMlAAFjJgABYygAAWMqAAFjKwABYywAAWMuAAFjLwABY24AAWNwAAFjcgABY3QAAWN3AAFjeAABY3kAAWN6AAFjewABY30AAWN/AAFjgAABY4EAAWODAAFjhAABY8MAAWPFAAFjxwABY8kAAWPMAAFjzQABY84AAWPPAAFj0AABY9IAAWPUAAFj1QABY9YAAWPYAAFj2QABZBgAAWQaAAFkHAABZB4AAWQhAAFkIgABZCMAAWQkAAFkJQABZCcAAWQpAAFkKgABZCsAAWQtAAFkLgABZHkAAWScAAFkvAABZNwAAWTeAAFk4AABZOIAAWTkAAFk5wABZOgAAWTpAAFk7AABZO0AAWTvAAFk8AABZPIAAWT1AAFk9gABZPcAAWT6AAFk+wABZQAAAWUNAAFlEgABZRQAAWUWAAFlGwABZR4AAWUhAAFlIwABZUgAAWVsAAFlkwABZbcAAWW6AAFlvAABZb4AAWXAAAFlwgABZcQAAWXFAAFlyAABZdUAAWXmAAFl6AABZeoAAWXsAAFl7gABZfAAAWXyAAFl9AABZfYAAWYHAAFmCgABZg0AAWYQAAFmEwABZhYAAWYZAAFmHAABZh8AAWYhAAFmYAABZmIAAWZkAAFmZgABZmkAAWZqAAFmawABZmwAAWZtAAFmbwABZnEAAWZyAAFmcwABZnUAAWZ2AAFmtQABZrcAAWa5AAFmuwABZr4AAWa/AAFmwAABZsEAAWbCAAFmxAABZsYAAWbHAAFmyAABZsoAAWbLAAFnCgABZwwAAWcPAAFnEQABZxQAAWcVAAFnFgABZxcAAWcYAAFnGgABZxwAAWcdAAFnHgABZyAAAWchAAFnLgABZy8AAWcwAAFnMgABZ3EAAWdzAAFndQABZ3cAAWd6AAFnewABZ3wAAWd9AAFnfgABZ4AAAWeCAAFngwABZ4QAAWeGAAFnhwABZ8YAAWfIAAFnygABZ8wAAWfPAAFn0AABZ9EAAWfSAAFn0wABZ9UAAWfXAAFn2AABZ9kAAWfbAAFn3AABaBsAAWgdAAFoHwABaCEAAWgkAAFoJQABaCYAAWgnAAFoKAABaCoAAWgsAAFoLQABaC4AAWgwAAFoMQABaHAAAWhyAAFodQABaHcAAWh6AAFoewABaHwAAWh9AAFofgABaIAAAWiCAAFogwABaIQAAWiGAAFohwABaJoAAWjZAAFo2wABaN0AAWjfAAFo4gABaOMAAWjkAAFo5QABaOYAAWjoAAFo6gABaOsAAWjsAAFo7gABaO8AAWkUAAFpOAABaV8AAWmDAAFphgABaYgAAWmKAAFpjAABaY4AAWmQAAFpkQABaZQAAWmhAAFpsAABabIAAWm0AAFptgABabgAAWm6AAFpvAABab4AAWnNAAFp0AABadMAAWnWAAFp2QABadwAAWnfAAFp4gABaeQAAWojAAFqJQABaicAAWopAAFqLAABai0AAWouAAFqLwABajAAAWoyAAFqNAABajUAAWo2AAFqOAABajkAAWp4AAFqegABanwAAWp+AAFqgQABaoIAAWqDAAFqhAABaoUAAWqHAAFqiQABaooAAWqLAAFqjQABao4AAWrNAAFqzwABatEAAWrTAAFq1gABatcAAWrYAAFq2QABatoAAWrcAAFq3gABat8AAWrgAAFq4gABauMAAWsiAAFrJAABayYAAWsoAAFrKwABaywAAWstAAFrLgABay8AAWsxAAFrMwABazQAAWs1AAFrNwABazgAAWt3AAFreQABa3sAAWt9AAFrgAABa4EAAWuCAAFrgwABa4QAAWuGAAFriAABa4kAAWuKAAFrjAABa40AAWvMAAFrzgABa9AAAWvSAAFr1QABa9YAAWvXAAFr2AABa9kAAWvbAAFr3QABa94AAWvfAAFr4QABa+IAAWwhAAFsIwABbCUAAWwnAAFsKgABbCsAAWwsAAFsLQABbC4AAWwwAAFsMgABbDMAAWw0AAFsNgABbDcAAWyCAAFspQABbMUAAWzlAAFs5wABbOkAAWzrAAFs7QABbPAAAWzxAAFs8gABbPUAAWz2AAFs+AABbPkAAWz7AAFs/gABbP8AAW0AAAFtAwABbQQAAW0JAAFtFgABbRsAAW0dAAFtHwABbSQAAW0nAAFtKgABbSwAAW1RAAFtdQABbZwAAW3AAAFtwwABbcUAAW3HAAFtyQABbcsAAW3NAAFtzgABbdEAAW3eAAFt7wABbfEAAW3zAAFt9QABbfcAAW35AAFt+wABbf0AAW3/AAFuEAABbhMAAW4WAAFuGQABbhwAAW4fAAFuIgABbiUAAW4oAAFuKgABbmkAAW5rAAFubQABbm8AAW5yAAFucwABbnQAAW51AAFudgABbngAAW56AAFuewABbnwAAW5+AAFufwABbr4AAW7AAAFuwgABbsQAAW7HAAFuyAABbskAAW7KAAFuywABbs0AAW7PAAFu0AABbtEAAW7TAAFu1AABbxMAAW8VAAFvGAABbxoAAW8dAAFvHgABbx8AAW8gAAFvIQABbyMAAW8lAAFvJgABbycAAW8pAAFvKgABbzcAAW84AAFvOQABbzsAAW96AAFvfAABb34AAW+AAAFvgwABb4QAAW+FAAFvhgABb4cAAW+JAAFviwABb4wAAW+NAAFvjwABb5AAAW/PAAFv0QABb9MAAW/VAAFv2AABb9kAAW/aAAFv2wABb9wAAW/eAAFv4AABb+EAAW/iAAFv5AABb+UAAXAkAAFwJgABcCgAAXAqAAFwLQABcC4AAXAvAAFwMAABcDEAAXAzAAFwNQABcDYAAXA3AAFwOQABcDoAAXB5AAFwewABcH0AAXB/AAFwggABcIMAAXCEAAFwhQABcIYAAXCIAAFwigABcIsAAXCMAAFwjgABcI8AAXDOAAFw0AABcNIAAXDUAAFw1wABcNgAAXDZAAFw2gABcNsAAXDdAAFw3wABcOAAAXDhAAFw4wABcOQAAXEJAAFxLQABcVQAAXF4AAFxewABcX0AAXF/AAFxgQABcYMAAXGFAAFxhgABcYkAAXGWAAFxpQABcacAAXGpAAFxqwABca0AAXGvAAFxsQABcbMAAXHCAAFxxQABccgAAXHLAAFxzgABcdEAAXHUAAFx1wABcdkAAXIYAAFyGgABchwAAXIeAAFyIQABciIAAXIjAAFyJAABciUAAXInAAFyKQABcioAAXIrAAFyLQABci4AAXJtAAFybwABcnEAAXJzAAFydgABcncAAXJ4AAFyeQABcnoAAXJ8AAFyfgABcn8AAXKAAAFyggABcoMAAXLCAAFyxAABcsYAAXLIAAFyywABcswAAXLNAAFyzgABcs8AAXLRAAFy0wABctQAAXLVAAFy1wABctgAAXMXAAFzGQABcxsAAXMdAAFzIAABcyEAAXMiAAFzIwABcyQAAXMmAAFzKAABcykAAXMqAAFzLAABcy0AAXNsAAFzbgABc3AAAXNyAAFzdQABc3YAAXN3AAFzeAABc3kAAXN7AAFzfQABc34AAXN/AAFzgQABc4IAAXPBAAFzwwABc8UAAXPHAAFzygABc8sAAXPMAAFzzQABc84AAXPQAAFz0gABc9MAAXPUAAFz1gABc9cAAXQWAAF0GAABdBoAAXQcAAF0HwABdCAAAXQhAAF0IgABdCMAAXQlAAF0JwABdCgAAXQpAAF0KwABdCwAAXR3AAF0mgABdLoAAXTaAAF03AABdN4AAXTgAAF04gABdOUAAXTmAAF05wABdOoAAXTrAAF07QABdO4AAXTwAAF08gABdPMAAXT0AAF09wABdPgAAXT9AAF1CgABdQ8AAXURAAF1EwABdRgAAXUbAAF1HgABdSAAAXVFAAF1aQABdZAAAXW0AAF1twABdbkAAXW7AAF1vQABdb8AAXXBAAF1wgABdcUAAXXSAAF14wABdeUAAXXnAAF16QABdesAAXXtAAF17wABdfEAAXXzAAF2BAABdgcAAXYKAAF2DQABdhAAAXYTAAF2FgABdhkAAXYcAAF2HgABdl0AAXZfAAF2YQABdmMAAXZmAAF2ZwABdmgAAXZpAAF2agABdmwAAXZuAAF2bwABdnAAAXZyAAF2cwABdrIAAXa0AAF2tgABdrgAAXa7AAF2vAABdr0AAXa+AAF2vwABdsEAAXbDAAF2xAABdsUAAXbHAAF2yAABdwcAAXcJAAF3DAABdw4AAXcRAAF3EgABdxMAAXcUAAF3FQABdxcAAXcZAAF3GgABdxsAAXcdAAF3HgABdysAAXcsAAF3LQABdy8AAXduAAF3cAABd3IAAXd0AAF3dwABd3gAAXd5AAF3egABd3sAAXd9AAF3fwABd4AAAXeBAAF3gwABd4QAAXfDAAF3xQABd8cAAXfJAAF3zAABd80AAXfOAAF3zwABd9AAAXfSAAF31AABd9UAAXfWAAF32AABd9kAAXgYAAF4GgABeBwAAXgeAAF4IQABeCIAAXgjAAF4JAABeCUAAXgnAAF4KQABeCoAAXgrAAF4LQABeC4AAXhtAAF4bwABeHEAAXhzAAF4dgABeHcAAXh4AAF4eQABeHoAAXh8AAF4fgABeH8AAXiAAAF4ggABeIMAAXjCAAF4xAABeMYAAXjIAAF4ywABeMwAAXjNAAF4zgABeM8AAXjRAAF40wABeNQAAXjVAAF41wABeNgAAXj9AAF5IQABeUgAAXlsAAF5bwABeXEAAXlzAAF5dQABeXcAAXl5AAF5egABeX0AAXmKAAF5mQABeZsAAXmdAAF5nwABeaEAAXmjAAF5pQABeacAAXm2AAF5uQABebwAAXm/AAF5wgABecUAAXnIAAF5ywABec0AAXoMAAF6DgABehAAAXoSAAF6FQABehYAAXoXAAF6GAABehkAAXobAAF6HQABeh4AAXofAAF6IQABeiIAAXphAAF6YwABemUAAXpnAAF6agABemsAAXpsAAF6bQABem4AAXpwAAF6cgABenMAAXp0AAF6dgABencAAXq2AAF6uAABeroAAXq8AAF6vwABesAAAXrBAAF6wgABesMAAXrFAAF6xwABesgAAXrJAAF6ywABesw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AAYZVAAGGVgABhlgAAYZaAAGGWwABhlwAAYZeAAGGXwABhp4AAYagAAGGogABhqQAAYanAAGGqAABhqkAAYaqAAGGqwABhq0AAYavAAGGsAABhrEAAYazAAGGtAABhvMAAYb1AAGG+AABhvoAAYb9AAGG/gABhv8AAYcAAAGHAQABhwMAAYcFAAGHBgABhwcAAYcJAAGHCgABhxcAAYcYAAGHGQABhxsAAYdaAAGHXAABh14AAYdgAAGHYwABh2QAAYdlAAGHZgABh2cAAYdpAAGHawABh2wAAYdtAAGHbwABh3AAAYevAAGHsQABh7MAAYe1AAGHuAABh7kAAYe6AAGHuwABh7wAAYe+AAGHwAABh8EAAYfCAAGHxAABh8UAAYgEAAGIBgABiAgAAYgKAAGIDQABiA4AAYgPAAGIEAABiBEAAYgTAAGIFQABiBYAAYgXAAGIGQABiBoAAYhZAAGIWwABiF4AAYhgAAGIYwABiGQAAYhlAAGIZgABiGcAAYhpAAGIawABiGwAAYhtAAGIbwABiHAAAYiEAAGIwwABiMUAAYjHAAGIyQABiMwAAYjNAAGIzgABiM8AAYjQAAGI0gABiNQAAYjVAAGI1gABiNgAAYjZAAGI/gABiSIAAYlJAAGJbQABiXAAAYlyAAGJdAABiXYAAYl4AAGJegABiXsAAYl+AAGJiwABiZoAAYmcAAGJngABiaAAAYmiAAGJpAABiaYAAYmoAAGJtwABiboAAYm9AAGJwAABicMAAYnGAAGJyQABicwAAYnOAAGKDQABig8AAYoRAAGKEwABihYAAYoXAAGKGAABihkAAYoaAAGKHAABih4AAYofAAGKIAABiiIAAYojAAGKYgABimQAAYpmAAGKaAABimsAAYpsAAGKbQABim4AAYpvAAGKcQABinMAAYp0AAGKdQABincAAYp4AAGKtwABirkAAYq7AAGKvQABisAAAYrBAAGKwgABisMAAYrEAAGKxgABisgAAYrJAAGKygABiswAAYrNAAGLDAABiw4AAYsQAAGLEgABixUAAYsWAAGLFwABixgAAYsZAAGLGwABix0AAYseAAGLHwABiyEAAYsiAAGLYQABi2MAAYtlAAGLZwABi2oAAYtrAAGLbAABi20AAYtuAAGLcAABi3IAAYtzAAGLdAABi3YAAYt3AAGLtgABi7gAAYu6AAGLvAABi78AAYvAAAGLwQABi8IAAYvDAAGLxQABi8cAAYvIAAGLyQABi8sAAYvMAAGMCwABjA0AAYwPAAGMEQABjBQAAYwVAAGMFgABjBcAAYwYAAGMGgABjBwAAYwdAAGMHgABjCAAAYwhAAGMbAABjI8AAYyvAAGMzwABjNEAAYzTAAGM1QABjNcAAYzaAAGM2wABjNwAAYzfAAGM4AABjOIAAYzjAAGM5QABjOgAAYzpAAGM6gABjO0AAYzuAAGM9wABjQQAAY0JAAGNCwABjQ0AAY0SAAGNFQABjRgAAY0aAAGNPwABjWMAAY2KAAGNrgABjbEAAY2zAAGNtQABjbcAAY25AAGNuwABjbwAAY2/AAGNzAABjd0AAY3fAAGN4QABjeMAAY3lAAGN5wABjekAAY3rAAGN7QABjf4AAY4BAAGOBAABjgcAAY4KAAGODQABjhAAAY4TAAGOFgABjhgAAY5XAAGOWQABjlsAAY5dAAGOYAABjmEAAY5iAAGOYwABjmQAAY5mAAGOaAABjmkAAY5qAAGObAABjm0AAY6sAAGOrgABjrAAAY6yAAGOtQABjrYAAY63AAGOuAABjrkAAY67AAGOvQABjr4AAY6/AAGOwQABjsIAAY8BAAGPAwABjwYAAY8IAAGPCwABjwwAAY8NAAGPDgABjw8AAY8RAAGPEwABjxQAAY8VAAGPFwABjxgAAY8lAAGPJgABjycAAY8pAAGPaAABj2oAAY9sAAGPbgABj3EAAY9yAAGPcwABj3QAAY91AAGPdwABj3kAAY96AAGPewABj30AAY9+AAGPvQABj78AAY/BAAGPwwABj8YAAY/HAAGPyAABj8kAAY/KAAGPzAABj84AAY/PAAGP0AABj9IAAY/TAAGQEgABkBQAAZAWAAGQGAABkBsAAZAcAAGQHQABkB4AAZAfAAGQIQABkCMAAZAkAAGQJQABkCcAAZAoAAGQZwABkGkAAZBsAAGQbgABkHEAAZByAAGQcwABkHQAAZB1AAGQdwABkHkAAZB6AAGQewABkH0AAZB+AAGQjQABkMwAAZDOAAGQ0AABkNIAAZDVAAGQ1gABkNcAAZDYAAGQ2QABkNsAAZDdAAGQ3gABkN8AAZDhAAGQ4gABkQcAAZErAAGRUgABkXYAAZF5AAGRewABkX0AAZF/AAGRgQABkYMAAZGEAAGRhwABkZQAAZGjAAGRpQABkacAAZGpAAGRqwABka0AAZGvAAGRsQABkcAAAZHDAAGRxgABkckAAZHMAAGRzwABkdIAAZHVAAGR1wABkhYAAZIYAAGSGgABkhwAAZIfAAGSIAABkiEAAZIiAAGSIwABkiUAAZInAAGSKAABkikAAZIrAAGSLAABkmsAAZJtAAGSbwABknEAAZJ0AAGSdQABknYAAZJ3AAGSeAABknoAAZJ8AAGSfQABkn4AAZKAAAGSgQABksAAAZLCAAGSxAABksYAAZLJAAGSygABkssAAZLMAAGSzQABks8AAZLRAAGS0gABktMAAZLVAAGS1gABkxUAAZMXAAGTGQABkxsAAZMeAAGTHwABkyAAAZMhAAGTIgABkyQAAZMmAAGTJwABkygAAZMqAAGTKwABk2oAAZNsAAGTbgABk3AAAZNzAAGTdAABk3UAAZN2AAGTdwABk3kAAZN7AAGTfAABk30AAZN/AAGTgAABk78AAZPBAAGTwwABk8UAAZPIAAGTyQABk8oAAZPLAAGTzAABk84AAZPQAAGT0QABk9IAAZPUAAGT1QABlBQAAZQWAAGUGAABlBoAAZQdAAGUHgABlB8AAZQgAAGUIQABlCMAAZQlAAGUJgABlCcAAZQpAAGUKgABlHUAAZSYAAGUuAABlNgAAZTaAAGU3AABlN4AAZTgAAGU4wABlOQAAZTlAAGU6AABlOkAAZTrAAGU7AABlO4AAZTxAAGU8gABlPMAAZT2AAGU9wABlPwAAZUJAAGVDgABlRAAAZUSAAGVFwABlRoAAZUdAAGVHwABlUQAAZVoAAGVjwABlbMAAZW2AAGVuAABlboAAZW8AAGVvgABlcAAAZXBAAGVxAABldEAAZXiAAGV5AABleYAAZXoAAGV6gABlewAAZXuAAGV8AABlfIAAZYDAAGWBgABlgkAAZYMAAGWDwABlhIAAZYVAAGWGAABlhsAAZYdAAGWXAABll4AAZZgAAGWYgABlmUAAZZmAAGWZwABlmgAAZZpAAGWawABlm0AAZZuAAGWbwABlnEAAZZyAAGWsQABlrMAAZa1AAGWtwABlroAAZa7AAGWvAABlr0AAZa+AAGWwAABlsIAAZbDAAGWxAABlsYAAZbHAAGXBgABlwgAAZcLAAGXDQABlxAAAZcRAAGXEgABlxMAAZcUAAGXFgABlxgAAZcZAAGXGgABlxwAAZcdAAGXKgABlysAAZcsAAGXLgABl20AAZdvAAGXcQABl3MAAZd2AAGXdwABl3gAAZd5AAGXegABl3wAAZd+AAGXfwABl4AAAZeCAAGXgwABl8IAAZfEAAGXxgABl8gAAZfLAAGXzAABl80AAZfOAAGXzwABl9EAAZfTAAGX1AABl9UAAZfXAAGX2AABmBcAAZgZAAGYGwABmB0AAZggAAGYIQABmCIAAZgjAAGYJAABmCYAAZgoAAGYKQABmCoAAZgsAAGYLQABmGwAAZhuAAGYcQABmHMAAZh2AAGYdwABmHgAAZh5AAGYegABmHwAAZh+AAGYfwABmIAAAZiCAAGYgwABmJcAAZjWAAGY2AABmNoAAZjcAAGY3wABmOAAAZjhAAGY4gABmOMAAZjlAAGY5wABmOgAAZjpAAGY6wABmOwAAZkRAAGZNQABmVwAAZmAAAGZgwABmYUAAZmHAAGZiQABmYsAAZmNAAGZjgABmZEAAZmeAAGZrQABma8AAZmxAAGZswABmbUAAZm3AAGZuQABmbsAAZnKAAGZzQABmdAAAZnTAAGZ1gABmdkAAZncAAGZ3wABmeEAAZogAAGaIgABmiQAAZomAAGaKQABmioAAZorAAGaLAABmi0AAZovAAGaMQABmjIAAZozAAGaNQABmjYAAZp1AAGadwABmnkAAZp7AAGafgABmn8AAZqAAAGagQABmoIAAZqEAAGahgABmocAAZqIAAGaigABmosAAZrKAAGazAABms4AAZrQAAGa0wABmtQAAZrVAAGa1gABmtcAAZrZAAGa2wABmtwAAZrdAAGa3wABmuAAAZsfAAGbIQABmyMAAZslAAGbKAABmykAAZsqAAGbKwABmywAAZsuAAGbMAABmzEAAZsyAAGbNAABmzUAAZt0AAGbdgABm3gAAZt6AAGbfQABm34AAZt/AAGbgAABm4EAAZuDAAGbhQABm4YAAZuHAAGbiQABm4oAAZvJAAGbywABm80AAZvPAAGb0gABm9MAAZvUAAGb1QABm9YAAZvYAAGb2gABm9sAAZvcAAGb3gABm98AAZweAAGcIAABnCIAAZwkAAGcJwABnCgAAZwpAAGcKgABnCsAAZwtAAGcLwABnDAAAZwxAAGcMwABnDQAAZx/AAGcogABnMIAAZziAAGc5AABnOYAAZzoAAGc6gABnO0AAZzuAAGc7wABnPIAAZzzAAGc9QABnPYAAZz4AAGc+wABnPwAAZz9AAGdAAABnQEAAZ0GAAGdEwABnRgAAZ0aAAGdHAABnSEAAZ0kAAGdJwABnSkAAZ1OAAGdcgABnZkAAZ29AAGdwAABncIAAZ3EAAGdxgABncgAAZ3KAAGdywABnc4AAZ3bAAGd7AABne4AAZ3wAAGd8gABnfQAAZ32AAGd+AABnfoAAZ38AAGeDQABnhAAAZ4TAAGeFgABnhkAAZ4cAAGeHwABniIAAZ4lAAGeJwABnmYAAZ5oAAGeagABnmwAAZ5vAAGecAABnnEAAZ5yAAGecwABnnUAAZ53AAGeeAABnnkAAZ57AAGefAABnrsAAZ69AAGevwABnsEAAZ7EAAGexQABnsYAAZ7HAAGeyAABnsoAAZ7MAAGezQABns4AAZ7QAAGe0QABnxAAAZ8SAAGfFQABnxcAAZ8aAAGfGwABnxwAAZ8dAAGfHgABnyAAAZ8iAAGfIwABnyQAAZ8mAAGfJwABnzQAAZ81AAGfNgABnzgAAZ93AAGfeQABn3sAAZ99AAGfgAABn4EAAZ+CAAGfgwABn4QAAZ+GAAGfiAABn4kAAZ+KAAGfjAABn40AAZ/MAAGfzgABn9AAAZ/SAAGf1QABn9YAAZ/XAAGf2AABn9kAAZ/bAAGf3QABn94AAZ/fAAGf4QABn+IAAaAhAAGgIwABoCUAAaAnAAGgKgABoCsAAaAsAAGgLQABoC4AAaAwAAGgMgABoDMAAaA0AAGgNgABoDcAAaB2AAGgeAABoHoAAaB8AAGgfwABoIAAAaCBAAGgggABoIMAAaCFAAGghwABoIgAAaCJAAGgiwABoIwAAaDLAAGgzQABoM8AAaDRAAGg1AABoNUAAaDWAAGg1wABoNgAAaDaAAGg3AABoN0AAaDeAAGg4AABoOEAAaEGAAGhKgABoVEAAaF1AAGheAABoXoAAaF8AAGhfgABoYAAAaGCAAGhgwABoYYAAaGTAAGhogABoaQAAaGmAAGhqAABoaoAAaGsAAGhrgABobAAAaG/AAGhwgABocUAAaHIAAGhywABoc4AAaHRAAGh1AABodYAAaIVAAGiFwABohkAAaIbAAGiHgABoh8AAaIgAAGiIQABoiIAAaIkAAGiJgABoicAAaIoAAGiKgABoisAAaJqAAGibAABom4AAaJwAAGicwABonQAAaJ1AAGidgABoncAAaJ5AAGiewABonwAAaJ9AAGifwABooAAAaK/AAGiwQABosMAAaLFAAGiyAABoskAAaLKAAGiywABoswAAaLOAAGi0AABotEAAaLSAAGi1AABotUAAaMUAAGjFgABoxgAAaMaAAGjHQABox4AAaMfAAGjIAABoyEAAaMjAAGjJQABoyYAAaMnAAGjKQABoyoAAaNpAAGjawABo20AAaNvAAGjcgABo3MAAaN0AAGjdQABo3YAAaN4AAGjegABo3sAAaN8AAGjfgABo38AAaO+AAGjwAABo8IAAaPEAAGjxwABo8gAAaPJAAGjygABo8sAAaPNAAGjzwABo9AAAaPRAAGj0wABo9QAAaQTAAGkFQABpBcAAaQZAAGkHAABpB0AAaQeAAGkHwABpCAAAaQiAAGkJAABpCUAAaQmAAGkKAABpCkAAaR0AAGklwABpLcAAaTXAAGk2QABpNsAAaTdAAGk3wABpOIAAaTjAAGk5AABpOcAAaToAAGk6gABpOsAAaTtAAGk8AABpPEAAaTyAAGk9QABpPYAAaT7AAGlCAABpQ0AAaUPAAGlEQABpRYAAaUZAAGlHAABpR4AAaVDAAGlZwABpY4AAaWyAAGltQABpbcAAaW5AAGluwABpb0AAaW/AAGlwAABpcMAAaXQAAGl4QABpeMAAaXlAAGl5wABpekAAaXrAAGl7QABpe8AAaXxAAGmAgABpgUAAaYIAAGmCwABpg4AAaYRAAGmFAABphcAAaYaAAGmHAABplsAAaZdAAGmXwABpmEAAaZkAAGmZQABpmYAAaZnAAGmaAABpmoAAaZsAAGmbQABpm4AAaZwAAGmcQABprAAAaayAAGmtAABprYAAaa5AAGmugABprsAAaa8AAGmvQABpr8AAabBAAGmwgABpsMAAabFAAGmxgABpwUAAacHAAGnCgABpwwAAacPAAGnEAABpxEAAacSAAGnEwABpxUAAacXAAGnGAABpxkAAacbAAGnHAABpykAAacqAAGnKwABpy0AAadsAAGnbgABp3AAAadyAAGndQABp3YAAad3AAGneAABp3kAAad7AAGnfQABp34AAad/AAGngQABp4IAAafBAAGnwwABp8UAAafHAAGnygABp8sAAafMAAGnzQABp84AAafQAAGn0gABp9MAAafUAAGn1gABp9cAAagWAAGoGAABqBoAAagcAAGoHwABqCAAAaghAAGoIgABqCMAAaglAAGoJwABqCgAAagpAAGoKwABqCwAAahrAAGobQABqG8AAahxAAGodAABqHUAAah2AAGodwABqHgAAah6AAGofAABqH0AAah+AAGogAABqIEAAajAAAGowgABqMQAAajGAAGoyQABqMoAAajLAAGozAABqM0AAajPAAGo0QABqNIAAajTAAGo1QABqNYAAaj7AAGpHwABqUYAAalqAAGpbQABqW8AAalxAAGpcwABqXUAAal3AAGpeAABqXsAAamIAAGplwABqZkAAambAAGpnQABqZ8AAamhAAGpowABqaUAAam0AAGptwABqboAAam9AAGpwAABqcMAAanGAAGpyQABqcsAAaoKAAGqDAABqg4AAaoQAAGqEwABqhQAAaoVAAGqFgABqhcAAaoZAAGqGwABqhwAAaodAAGqHwABqiAAAapfAAGqYQABqmMAAaplAAGqaAABqmkAAapqAAGqawABqmwAAapuAAGqcAABqnEAAapyAAGqdAABqnUAAaq0AAGqtgABqrgAAaq6AAGqvQABqr4AAaq/AAGqwAABqsEAAarDAAGqxQABqsYAAarHAAGqyQABqsoAAasJAAGrCwABqw0AAasPAAGrEgABqxMAAasUAAGrFQABqxYAAasYAAGrGgABqxsAAascAAGrHgABqx8AAateAAGrYAABq2IAAatkAAGrZwABq2gAAatpAAGragABq2sAAattAAGrbwABq3AAAatxAAGrcwABq3QAAauzAAGrtQABq7cAAau5AAGrvAABq70AAau+AAGrvwABq8AAAavCAAGrxAABq8UAAavGAAGryAABq8kAAawIAAGsCgABrAwAAawOAAGsEQABrBIAAawTAAGsFAABrBUAAawXAAGsGQABrBoAAawbAAGsHQABrB4AAaxpAAGsjAABrKwAAazMAAGszgABrNAAAazSAAGs1AABrNcAAazYAAGs2QABrNwAAazdAAGs3wABrOAAAaziAAGs5QABrOYAAaznAAGs6gABrOsAAazwAAGs/QABrQIAAa0EAAGtBgABrQsAAa0OAAGtEQABrRMAAa04AAGtXAABrYMAAa2nAAGtqgABrawAAa2uAAGtsAABrbIAAa20AAGttQABrbgAAa3FAAGt1gABrdgAAa3aAAGt3AABrd4AAa3gAAGt4gABreQAAa3mAAGt9wABrfoAAa39AAGuAAABrgMAAa4GAAGuCQABrgwAAa4PAAGuEQABrlAAAa5SAAGuVAABrlYAAa5ZAAGuWgABrlsAAa5cAAGuXQABrl8AAa5hAAGuYgABrmMAAa5lAAGuZgABrqUAAa6nAAGuqQABrqsAAa6uAAGurwABrrAAAa6xAAGusgABrrQAAa62AAGutwABrrgAAa66AAGuuwABrvoAAa78AAGu/wABrwEAAa8EAAGvBQABrwYAAa8HAAGvCAABrwoAAa8MAAGvDQABrw4AAa8QAAGvEQABrx4AAa8fAAGvIAABryIAAa9hAAGvYwABr2UAAa9nAAGvagABr2sAAa9sAAGvbQABr24AAa9wAAGvcgABr3MAAa90AAGvdgABr3cAAa+2AAGvuAABr7oAAa+8AAGvvwABr8AAAa/BAAGvwgABr8MAAa/FAAGvxwABr8gAAa/JAAGvywABr8wAAbALAAGwDQABsA8AAbARAAGwFAABsBUAAbAWAAGwFwABsBgAAbAaAAGwHAABsB0AAbAeAAGwIAABsCEAAbBgAAGwYgABsGQAAbBmAAGwaQABsGoAAbBrAAGwbAABsG0AAbBvAAGwcQABsHIAAbBzAAGwdQABsHYAAbC1AAGwtwABsLkAAbC7AAGwvgABsL8AAbDAAAGwwQABsMIAAbDEAAGwxgABsMcAAbDIAAGwygABsMsAAbDwAAGxFAABsTsAAbFfAAGxYgABsWQAAbFmAAGxaAABsWoAAbFsAAGxbQABsXAAAbF9AAGxjAABsY4AAbGQAAGxkgABsZQAAbGWAAGxmAABsZoAAbGpAAGxrAABsa8AAbGyAAGxtQABsbgAAbG7AAGxvgABscAAAbH/AAGyAQABsgMAAbIFAAGyCAABsgkAAbIKAAGyCwABsgwAAbIOAAGyEAABshEAAbISAAGyFAABshUAAbJUAAGyVgABslgAAbJaAAGyXQABsl4AAbJfAAGyYAABsmEAAbJjAAGyZQABsmYAAbJnAAGyaQABsmoAAbKpAAGyqwABsq0AAbKvAAGysgABsrMAAbK0AAGytQABsrYAAbK4AAGyugABsrsAAbK8AAGyvgABsr8AAbL+AAGzAAABswMAAbMFAAGzCAABswkAAbMKAAGzCwABswwAAbMOAAGzEAABsxEAAbMSAAGzFAABsxUAAbNUAAGzVgABs1gAAbNaAAGzXQABs14AAbNfAAGzYAABs2EAAbNjAAGzZQABs2YAAbNnAAGzaQABs2oAAbOpAAGzqwABs60AAbOvAAGzsgABs7MAAbO0AAGztQABs7YAAbO4AAGzugABs7sAAbO8AAGzvgABs78AAbP+AAG0AAABtAMAAbQFAAG0CAABtAkAAbQKAAG0CwABtAwAAbQOAAG0EAABtBEAAbQSAAG0FAABtBUAAbRgAAG0gwABtKMAAbTDAAG0xQABtMcAAbTJAAG0ywABtM4AAbTPAAG00AABtNMAAbTUAAG01gABtNcAAbTZAAG03AABtN0AAbTeAAG04QABtOIAAbTnAAG09AABtPkAAbT7AAG0/QABtQIAAbUFAAG1CAABtQoAAbUvAAG1UwABtXoAAbWeAAG1oQABtaMAAbWlAAG1pwABtakAAbWrAAG1rAABta8AAbW8AAG1zQABtc8AAbXRAAG10wABtdUAAbXXAAG12QABtdsAAbXdAAG17gABtfEAAbX0AAG19wABtfoAAbX9AAG2AAABtgMAAbYGAAG2CAABtkcAAbZJAAG2SwABtk0AAbZQAAG2UQABtlIAAbZTAAG2VAABtlYAAbZYAAG2WQABtloAAbZcAAG2XQABtpwAAbaeAAG2oAABtqIAAbalAAG2pgABtqcAAbaoAAG2qQABtqsAAbatAAG2rgABtq8AAbaxAAG2sgABtvEAAbbzAAG29gABtvgAAbb7AAG2/AABtv0AAbb+AAG2/wABtwEAAbcDAAG3BAABtwUAAbcHAAG3CAABtxUAAbcWAAG3FwABtxkAAbdYAAG3WgABt1wAAbdeAAG3YQABt2IAAbdjAAG3ZAABt2UAAbdnAAG3aQABt2oAAbdrAAG3bQABt24AAbetAAG3rwABt7EAAbezAAG3tgABt7cAAbe4AAG3uQABt7oAAbe8AAG3vgABt78AAbfAAAG3wgABt8MAAbgCAAG4BAABuAYAAbgIAAG4CwABuAwAAbgNAAG4DgABuA8AAbgRAAG4EwABuBQAAbgVAAG4FwABuBgAAbhXAAG4WQABuFsAAbhdAAG4YAABuGEAAbhiAAG4YwABuGQAAbhmAAG4aAABuGkAAbhqAAG4bAABuG0AAbisAAG4rgABuLAAAbiyAAG4tQABuLYAAbi3AAG4uAABuLkAAbi7AAG4vQABuL4AAbi/AAG4wQABuMIAAbjnAAG5CwABuTIAAblWAAG5WQABuVsAAbldAAG5XwABuWEAAbljAAG5ZAABuWcAAbl0AAG5gwABuYUAAbmHAAG5iQABuYsAAbmNAAG5jwABuZEAAbmgAAG5owABuaYAAbmpAAG5rAABua8AAbmyAAG5tQABubcAAbn2AAG5+AABufoAAbn8AAG5/wABugAAAboBAAG6AgABugMAAboFAAG6BwABuggAAboJAAG6CwABugwAAbpLAAG6TQABuk8AAbpRAAG6VAABulUAAbpWAAG6VwABulgAAbpaAAG6XAABul0AAbpeAAG6YAABumEAAbqgAAG6ogABuqQAAbqmAAG6qQABuqoAAbqrAAG6rAABuq0AAbqvAAG6sQABurIAAbqzAAG6tQABurYAAbr1AAG69wABuvoAAbr8AAG6/wABuwAAAbsBAAG7AgABuwMAAbsFAAG7BwABuwgAAbsJAAG7CwABuwwAAbtLAAG7TQABu08AAbtRAAG7VAABu1UAAbtWAAG7VwABu1gAAbtaAAG7XAABu10AAbteAAG7YAABu2EAAbugAAG7ogABu6QAAbumAAG7qQABu6oAAburAAG7rAABu60AAbuvAAG7sQABu7IAAbuzAAG7tQABu7YAAbv1AAG79wABu/oAAbv8AAG7/wABvAAAAbwBAAG8AgABvAMAAbwFAAG8BwABvAgAAbwJAAG8CwABvAwAAbxXAAG8egABvJoAAby6AAG8vAABvL4AAbzAAAG8wgABvMUAAbzGAAG8xwABvMoAAbzLAAG8zQABvM4AAbzQAAG80wABvNQAAbzVAAG82AABvNkAAbzeAAG86wABvPAAAbzyAAG89AABvPkAAbz8AAG8/wABvQEAAb0mAAG9SgABvXEAAb2VAAG9mAABvZoAAb2cAAG9ngABvaAAAb2iAAG9owABvaYAAb2zAAG9xAABvcYAAb3IAAG9ygABvcwAAb3OAAG90AABvdIAAb3UAAG95QABvegAAb3rAAG97gABvfEAAb30AAG99wABvfoAAb39AAG9/wABvj4AAb5AAAG+QgABvkQAAb5HAAG+SAABvkkAAb5KAAG+SwABvk0AAb5PAAG+UAABvlEAAb5TAAG+VAABvpMAAb6VAAG+lwABvpkAAb6cAAG+nQABvp4AAb6fAAG+oAABvqIAAb6kAAG+pQABvqYAAb6oAAG+qQABvugAAb7qAAG+7QABvu8AAb7yAAG+8wABvvQAAb71AAG+9gABvvgAAb76AAG++wABvvwAAb7+AAG+/wABvwwAAb8NAAG/DgABvxAAAb9PAAG/UQABv1MAAb9VAAG/WAABv1kAAb9aAAG/WwABv1wAAb9eAAG/YAABv2EAAb9iAAG/ZAABv2UAAb+kAAG/pgABv6gAAb+qAAG/rQABv64AAb+vAAG/sAABv7EAAb+zAAG/tQABv7YAAb+3AAG/uQABv7oAAb/5AAG/+wABv/0AAb//AAHAAgABwAMAAcAEAAHABQABwAYAAcAIAAHACgABwAsAAcAMAAHADgABwA8AAcBOAAHAUAABwFMAAcBVAAHAWAABwFkAAcBaAAHAWwABwFwAAcBeAAHAYAABwGEAAcBiAAHAZAABwGUAAcBzAAHAsgABwLQAAcC2AAHAuAABwLsAAcC8AAHAvQABwL4AAcC/AAHAwQABwMMAAcDEAAHAxQABwMcAAcDIAAHA7QABwREAAcE4AAHBXAABwV8AAcFhAAHBYwABwWUAAcFnAAHBaQABwWoAAcFtAAHBegABwYkAAcGLAAHBjQABwY8AAcGRAAHBkwABwZUAAcGXAAHBpgABwakAAcGsAAHBrwABwbIAAcG1AAHBuAABwbsAAcG9AAHB/AABwf4AAcIAAAHCAgABwgUAAcIGAAHCBwABwggAAcIJAAHCCwABwg0AAcIOAAHCDwABwhEAAcISAAHCUQABwlMAAcJVAAHCVwABwloAAcJbAAHCXAABwl0AAcJeAAHCYAABwmIAAcJjAAHCZAABwmYAAcJnAAHCpgABwqgAAcKqAAHCrAABwq8AAcKwAAHCsQABwrIAAcKzAAHCtQABwrcAAcK4AAHCuQABwrsAAcK8AAHC+wABwv0AAcL/AAHDAQABwwQAAcMFAAHDBgABwwcAAcMIAAHDCgABwwwAAcMNAAHDDgABwxAAAcMRAAHDUAABw1IAAcNUAAHDVgABw1kAAcNaAAHDWwABw1wAAcNdAAHDXwABw2EAAcNiAAHDYwABw2UAAcNmAAHDpQABw6cAAcOpAAHDqwABw64AAcOvAAHDsAABw7EAAcOyAAHDtAABw7YAAcO3AAHDuAABw7oAAcO7AAHD+gABw/wAAcP+AAHEAAABxAMAAcQEAAHEBQABxAYAAcQHAAHECQABxAsAAcQMAAHEDQABxA8AAcQQAAHEWwABxH4AAcSeAAHEvgABxMAAAcTCAAHExAABxMYAAcTJAAHEygABxMsAAcTOAAHEzwABxNEAAcTSAAHE1AABxNcAAcTYAAHE2QABxNwAAcTdAAHE5gABxPMAAcT4AAHE+gABxPwAAcUBAAHFBAABxQcAAcUJAAHFLgABxVIAAcV5AAHFnQABxaAAAcWiAAHFpAABxaYAAcWoAAHFqgABxasAAcWuAAHFuwABxcwAAcXOAAHF0AABxdIAAcXUAAHF1gABxdgAAcXaAAHF3AABxe0AAcXwAAHF8wABxfYAAcX5AAHF/AABxf8AAcYCAAHGBQABxgcAAcZGAAHGSAABxkoAAcZMAAHGTwABxlAAAcZRAAHGUgABxlMAAcZVAAHGVwABxlgAAcZZAAHGWwABxlwAAcabAAHGnQABxp8AAcahAAHGpAABxqUAAcamAAHGpwABxqgAAcaqAAHGrAABxq0AAcauAAHGsAABxrEAAcbwAAHG8gABxvUAAcb3AAHG+gABxvsAAcb8AAHG/QABxv4AAccAAAHHAgABxwMAAccEAAHHBgABxwcAAccUAAHHFQABxxYAAccYAAHHVwABx1kAAcdbAAHHXQABx2AAAcdhAAHHYgABx2MAAcdkAAHHZgABx2gAAcdpAAHHagABx2wAAcdtAAHHrAABx64AAcewAAHHsgABx7UAAce2AAHHtwABx7gAAce5AAHHuwABx70AAce+AAHHvwABx8EAAcfCAAHIAQAByAMAAcgFAAHIBwAByAoAAcgLAAHIDAAByA0AAcgOAAHIEAAByBIAAcgTAAHIFAAByBYAAcgXAAHIVgAByFgAAchaAAHIXAAByF8AAchgAAHIYQAByGIAAchjAAHIZQAByGcAAchoAAHIaQAByGsAAchsAAHIqwAByK0AAcivAAHIsQAByLQAAci1AAHItgAByLcAAci4AAHIugAByLwAAci9AAHIvgAByMAAAcjBAAHI5gAByQoAAckxAAHJVQAByVgAAclaAAHJXAAByV4AAclgAAHJYgAByWMAAclmAAHJcwAByYIAAcmEAAHJhgAByYgAAcmKAAHJjAAByY4AAcmQAAHJnwAByaIAAcmlAAHJqAAByasAAcmuAAHJsQABybQAAcm2AAHJ9QAByfcAAcn5AAHJ+wAByf4AAcn/AAHKAAABygEAAcoCAAHKBAABygYAAcoHAAHKCAABygoAAcoLAAHKSgABykwAAcpOAAHKUAABylMAAcpUAAHKVQABylYAAcpXAAHKWQABylsAAcpcAAHKXQAByl8AAcpgAAHKnwAByqEAAcqjAAHKpQAByqgAAcqpAAHKqgAByqsAAcqsAAHKrgAByrAAAcqxAAHKsgAByrQAAcq1AAHK9AAByvYAAcr4AAHK+gAByv0AAcr+AAHK/wABywAAAcsBAAHLAwABywUAAcsGAAHLBwABywkAAcsKAAHLSQABy0sAActNAAHLTwABy1IAActTAAHLVAABy1UAActWAAHLWAABy1oAActbAAHLXAABy14AActfAAHLngABy6AAAcuiAAHLpAABy6cAAcuoAAHLqQABy6oAAcurAAHLrQABy68AAcuwAAHLsQABy7MAAcu0AAHL8wABy/UAAcv3AAHL+QABy/wAAcv9AAHL/gABy/8AAcwAAAHMAgABzAQAAcwFAAHMBgABzAgAAcwJAAHMVAABzHcAAcyXAAHMtwABzLkAAcy7AAHMvQABzL8AAczCAAHMwwABzMQAAczHAAHMyAABzMoAAczLAAHMzQABzNAAAczRAAHM0gABzNUAAczWAAHM2wABzOgAAcztAAHM7wABzPEAAcz2AAHM+QABzPwAAcz+AAHNIwABzUcAAc1uAAHNkgABzZUAAc2XAAHNmQABzZsAAc2dAAHNnwABzaAAAc2jAAHNsAABzcEAAc3DAAHNxQABzccAAc3JAAHNywABzc0AAc3PAAHN0QABzeIAAc3lAAHN6AABzesAAc3uAAHN8QABzfQAAc33AAHN+gABzfwAAc47AAHOPQABzj8AAc5BAAHORAABzkUAAc5GAAHORwABzkgAAc5KAAHOTAABzk0AAc5OAAHOUAABzlEAAc6QAAHOkgABzpQAAc6WAAHOmQABzpoAAc6bAAHOnAABzp0AAc6fAAHOoQABzqIAAc6jAAHOpQABzqYAAc7lAAHO5wABzuoAAc7sAAHO7wABzvAAAc7xAAHO8gABzvMAAc71AAHO9wABzvgAAc75AAHO+wABzvwAAc8JAAHPCgABzwsAAc8NAAHPTAABz04AAc9QAAHPUgABz1UAAc9WAAHPVwABz1gAAc9ZAAHPWwABz10AAc9eAAHPXwABz2EAAc9iAAHPoQABz6MAAc+lAAHPpwABz6oAAc+rAAHPrAABz60AAc+uAAHPsAABz7IAAc+zAAHPtAABz7YAAc+3AAHP9gABz/gAAc/6AAHP/AABz/8AAdAAAAHQAQAB0AIAAdADAAHQBQAB0AcAAdAIAAHQCQAB0AsAAdAMAAHQSwAB0E0AAdBPAAHQUQAB0FQAAdBVAAHQVgAB0FcAAdBYAAHQWgAB0FwAAdBdAAHQXgAB0GAAAdBhAAHQoAAB0KIAAdCkAAHQpgAB0KkAAdCqAAHQqwAB0KwAAdCtAAHQrwAB0LEAAdCyAAHQswAB0LUAAdC2AAHQ2wAB0P8AAdEmAAHRSgAB0U0AAdFPAAHRUQAB0VMAAdFVAAHRVwAB0VgAAdFbAAHRaAAB0XcAAdF5AAHRewAB0X0AAdF/AAHRgQAB0YMAAdGFAAHRlAAB0ZcAAdGaAAHRnQAB0aAAAdGjAAHRpgAB0akAAdGrAAHR6gAB0ewAAdHuAAHR8AAB0fMAAdH0AAHR9QAB0fYAAdH3AAHR+QAB0fsAAdH8AAHR/QAB0f8AAdIAAAHSPwAB0kEAAdJDAAHSRQAB0kgAAdJJAAHSSgAB0ksAAdJMAAHSTgAB0lAAAdJRAAHSUgAB0lQAAdJVAAHSlAAB0pYAAdKYAAHSmgAB0p0AAdKeAAHSnwAB0qAAAdKhAAHSowAB0qUAAdKmAAHSpwAB0qkAAdKqAAHS6QAB0usAAdLtAAHS7wAB0vIAAdLzAAHS9AAB0vUAAdL2AAHS+AAB0voAAdL7AAHS/AAB0v4AAdL/AAHTPgAB00AAAdNCAAHTRAAB00cAAdNIAAHTSQAB00oAAdNLAAHTTQAB008AAdNQAAHTUQAB01MAAdNUAAHTkwAB05UAAdOXAAHTmQAB05wAAdOdAAHTngAB058AAdOgAAHTogAB06QAAdOlAAHTpgAB06gAAdOpAAHT6AAB0+oAAdPsAAHT7gAB0/EAAdPyAAHT8wAB0/QAAdP1AAHT9wAB0/kAAdP6AAHT+wAB0/0AAdP+AAHUBwAB1AgAAdQKAAHUSQAB1Es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AAgDNAAIAzgACAM8AAgDRAAIA0gACAN8AAgDgAAIA4QACAOMAAgEiAAIBJAACASYAAgEoAAIBKwACASwAAgEtAAIBLgACAS8AAgExAAIBMwACATQAAgE1AAIBNwACATgAAgF3AAIBeQACAXsAAgF9AAIBgAACAYEAAgGCAAIBgwACAYQAAgGGAAIBiAACAYkAAgGKAAIBjAACAY0AAgHMAAIBzgACAdAAAgHSAAIB1QACAdYAAgHXAAIB2AACAdkAAgHbAAIB3QACAd4AAgHfAAIB4QACAeIAAgIhAAICIwACAiUAAgInAAICKgACAisAAgIsAAICLQACAi4AAgIwAAICMgACAjMAAgI0AAICNgACAjcAAgJ2AAICeAACAnoAAgJ8AAICfwACAoAAAgKBAAICggACAoMAAgKFAAIChwACAogAAgKJAAICiwACAowAAgKxAAIC1QACAvwAAgMgAAIDIwACAyUAAgMnAAIDKQACAysAAgMtAAIDLgACAzEAAgM+AAIDTQACA08AAgNRAAIDUwACA1UAAgNXAAIDWQACA1sAAgNqAAIDbQACA3AAAgNzAAIDdgACA3kAAgN8AAIDfwACA4EAAgPAAAIDwgACA8QAAgPGAAIDyQACA8oAAgPLAAIDzAACA80AAgPPAAID0QACA9IAAgPTAAID1QACA9YAAgQVAAIEFwACBBkAAgQbAAIEHgACBB8AAgQgAAIEIQACBCIAAgQkAAIEJgACBCcAAgQoAAIEKgACBCsAAgRqAAIEbAACBG4AAgRwAAIEcwACBHQAAgR1AAIEdgACBHcAAgR5AAIEewACBHwAAgR9AAIEfwACBIAAAgS/AAIEwQACBMQAAgTGAAIEyQACBMoAAgTLAAIEzAACBM0AAgTPAAIE0QACBNIAAgTTAAIE1QACBNYAAgUZAAIFPQACBWEAAgWEAAIFqwACBcsAAgXyAAIGGQACBjkAAgZdAAIGgQACBoMAAgaGAAIGiAACBooAAgaMAAIGjwACBpIAAgaUAAIGlgACBpkAAgabAAIGnQACBqAAAgajAAIGpAACBqkAAga2AAIGuQACBrsAAga+AAIGwQACBsMAAgboAAIHDAACBzMAAgdXAAIHWgACB1wAAgdeAAIHYAACB2IAAgdkAAIHZQACB2gAAgd1AAIHiAACB4oAAgeMAAIHjgACB5AAAgeSAAIHlAACB5YAAgeYAAIHmgACB60AAgewAAIHswACB7YAAge5AAIHvAACB78AAgfCAAIHxQACB8gAAgfKAAIICQACCAsAAggOAAIIEAACCBMAAggUAAIIFQACCBYAAggXAAIIGQACCBsAAggcAAIIHQACCB8AAgggAAIIKQACCCoAAggsAAIIawACCG0AAghvAAIIcQACCHQAAgh1AAIIdgACCHcAAgh4AAIIegACCHwAAgh9AAIIfgACCIAAAgiBAAIIwAACCMIAAgjFAAIIxwACCMoAAgjLAAIIzAACCM0AAgjOAAII0AACCNIAAgjTAAII1AACCNYAAgjXAAII4AACCOEAAgjjAAIJIgACCSQAAgkmAAIJKAACCSsAAgksAAIJLQACCS4AAgkvAAIJMQACCTMAAgk0AAIJNQACCTcAAgk4AAIJdwACCXkAAgl8AAIJfgACCYEAAgmCAAIJgwACCYQAAgmFAAIJhwACCYkAAgmKAAIJiwACCY0AAgmOAAIJlwACCZgAAgmaAAIJ2QACCdsAAgndAAIJ3wACCeIAAgnjAAIJ5AACCeUAAgnmAAIJ6AACCeoAAgnrAAIJ7AACCe4AAgnvAAIKLgACCjAAAgozAAIKNQACCjgAAgo5AAIKOgACCjsAAgo8AAIKPgACCkAAAgpBAAIKQgACCkQAAgpFAAIKUgACClMAAgpUAAIKVgACCpUAAgqXAAIKmQACCpsAAgqeAAIKnwACCqAAAgqhAAIKogACCqQAAgqmAAIKpwACCqgAAgqqAAIKqwACCuoAAgrsAAIK7gACCvAAAgrzAAIK9AACCvUAAgr2AAIK9wACCvkAAgr7AAIK/AACCv0AAgr/AAILAAACCw0AAgsWAAILGQACCxsAAgseAAILIQACCyoAAgstAAILMAACCzMAAgs2AAILOAACCz0AAgtCAAILRwACC5IAAgu1AAIL1QACC/UAAgv3AAIL+QACC/sAAgv9AAIMAAACDAEAAgwCAAIMBQACDAYAAgwIAAIMCQACDAsAAgwOAAIMDwACDBAAAgwTAAIMFAACDBkAAgwmAAIMKwACDC0AAgwvAAIMNAACDDcAAgw6AAIMPAACDGEAAgyFAAIMrAACDNAAAgzTAAIM1QACDNcAAgzZAAIM2wACDN0AAgzeAAIM4QACDO4AAgz/AAINAQACDQM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AAhfcAAIX3QACF94AAhffAAIX4AACF+IAAhfkAAIX5QACF+YAAhfoAAIX6QACGA4AAhgyAAIYWQACGH0AAhiAAAIYggACGIQAAhiGAAIYiAACGIoAAhiLAAIYjgACGJsAAhiqAAIYrAACGK4AAhiwAAIYsgACGLQAAhi2AAIYuAACGMcAAhjKAAIYzQACGNAAAhjTAAIY1gACGNkAAhjcAAIY3gACGR0AAhkfAAIZIgACGSQAAhknAAIZKAACGSkAAhkqAAIZKwACGS0AAhkvAAIZMAACGTEAAhkzAAIZNAACGXMAAhl1AAIZdwACGXkAAhl8AAIZfQACGX4AAhl/AAIZgAACGYIAAhmEAAIZhQACGYYAAhmIAAIZiQACGcgAAhnKAAIZzAACGc4AAhnRAAIZ0gACGdMAAhnUAAIZ1QACGdcAAhnZAAIZ2gACGdsAAhndAAIZ3gACGh0AAhofAAIaIgACGiQAAhonAAIaKAACGikAAhoqAAIaKwACGi0AAhovAAIaMAACGjEAAhozAAIaNAACGnMAAhp1AAIadwACGnkAAhp8AAIafQACGn4AAhp/AAIagAACGoIAAhqEAAIahQACGoYAAhqIAAIaiQACGsgAAhrKAAIazAACGs4AAhrRAAIa0gACGtMAAhrUAAIa1QACGtcAAhrZAAIa2gACGtsAAhrdAAIa3gACGx0AAhsfAAIbIQACGyMAAhsmAAIbJwACGygAAhspAAIbKgACGywAAhsuAAIbLwACGzAAAhsyAAIbMwACG34AAhuhAAIbwQACG+EAAhvjAAIb5QACG+cAAhvpAAIb7AACG+0AAhvuAAIb8QACG/IAAhv0AAIb9QACG/cAAhv6AAIb+wACG/wAAhv/AAIcAAACHAkAAhwWAAIcGwACHB0AAhwfAAIcJAACHCcAAhwqAAIcLAACHFEAAhx1AAIcnAACHMAAAhzDAAIcxQACHMcAAhzJAAIcywACHM0AAhzOAAIc0QACHN4AAhzvAAIc8QACHPMAAhz1AAIc9wACHPkAAhz7AAIc/QACHP8AAh0QAAIdEwACHRYAAh0ZAAIdHAACHR8AAh0iAAIdJQACHSgAAh0qAAIdaQACHWsAAh1tAAIdbwACHXIAAh1zAAIddAACHXUAAh12AAIdeAACHXoAAh17AAIdfAACHX4AAh1/AAIdvgACHcAAAh3CAAIdxAACHccAAh3IAAIdyQACHcoAAh3LAAIdzQACHc8AAh3QAAId0QACHdMAAh3UAAIeEwACHhUAAh4YAAIeGgACHh0AAh4eAAIeHwACHiAAAh4hAAIeIwACHiUAAh4mAAIeJwACHikAAh4qAAIeNwACHjgAAh45AAIeOwACHnoAAh58AAIefgACHoAAAh6DAAIehAACHoUAAh6GAAIehwACHokAAh6LAAIejAACHo0AAh6PAAIekAACHs8AAh7RAAIe0wACHtUAAh7YAAIe2QACHtoAAh7bAAIe3AACHt4AAh7gAAIe4QACHuIAAh7kAAIe5QACHyQAAh8mAAIfKAACHyoAAh8tAAIfLgACHy8AAh8wAAIfMQACHzMAAh81AAIfNgACHzcAAh85AAIfOgACH3kAAh97AAIffQACH38AAh+CAAIfgwACH4QAAh+FAAIfhgACH4gAAh+KAAIfiwACH4wAAh+OAAIfjwACH84AAh/QAAIf0gACH9QAAh/XAAIf2AACH9kAAh/aAAIf2wACH90AAh/fAAIf4AACH+EAAh/jAAIf5AACIAkAAiAtAAIgVAACIHgAAiB7AAIgfQACIH8AAiCBAAIggwACIIUAAiCGAAIgiQACIJYAAiClAAIgpwACIKkAAiCrAAIgrQACIK8AAiCxAAIgswACIMIAAiDFAAIgyAACIMsAAiDOAAIg0QACINQAAiDXAAIg2QACIRgAAiEaAAIhHQACIR8AAiEiAAIhIwACISQAAiElAAIhJgACISgAAiEqAAIhKwACISwAAiEuAAIhLwACIW4AAiFwAAIhcgACIXQAAiF3AAIheAACIXkAAiF6AAIhewACIX0AAiF/AAIhgAACIYEAAiGDAAIhhAACIcMAAiHFAAIhxwACIckAAiHMAAIhzQACIc4AAiHPAAIh0AACIdIAAiHUAAIh1QACIdYAAiHYAAIh2QACIhgAAiIaAAIiHQACIh8AAiIiAAIiIwACIiQAAiIlAAIiJgACIigAAiIqAAIiKwACIiwAAiIuAAIiLwACIm4AAiJwAAIicgACInQAAiJ3AAIieAACInkAAiJ6AAIiewACIn0AAiJ/AAIigAACIoEAAiKDAAIihAACIsMAAiLFAAIixwACIskAAiLMAAIizQACIs4AAiLPAAIi0AACItIAAiLUAAIi1QACItYAAiLYAAIi2QACIxgAAiMaAAIjHAACIx4AAiMhAAIjIgACIyMAAiMkAAIjJQACIycAAiMpAAIjKgACIysAAiMtAAIjLgACI3kAAiOcAAIjvAACI9wAAiPeAAIj4AACI+IAAiPkAAIj5wACI+gAAiPpAAIj7AACI+0AAiPvAAIj8AACI/IAAiP1AAIj9gACI/cAAiP6AAIj+wACJAAAAiQNAAIkEgACJBQAAiQWAAIkGwACJB4AAiQhAAIkIwACJEgAAiRsAAIkkwACJLcAAiS6AAIkvAACJL4AAiTAAAIkwgACJMQAAiTFAAIkyAACJNUAAiTmAAIk6AACJOoAAiTsAAIk7gACJPAAAiTyAAIk9AACJPYAAiUHAAIlCgACJQ0AAiUQAAIlEwACJRYAAiUZAAIlHAACJR8AAiUhAAIlYAACJWIAAiVkAAIlZgACJWkAAiVqAAIlawACJWwAAiVtAAIlbwACJXEAAiVyAAIlcwACJXUAAiV2AAIltQACJbcAAiW5AAIluwACJb4AAiW/AAIlwAACJcEAAiXCAAIlxAACJcYAAiXHAAIlyAACJcoAAiXLAAImCgACJgwAAiYPAAImEQACJhQAAiYVAAImFgACJhcAAiYYAAImGgACJhwAAiYdAAImHgACJiAAAiYhAAImLgACJi8AAiYwAAImMgACJnEAAiZzAAImdQACJncAAiZ6AAImewACJnwAAiZ9AAImfgACJoAAAiaCAAImgwACJoQAAiaGAAImhwACJsYAAibIAAImygACJswAAibPAAIm0AACJtEAAibSAAIm0wACJtUAAibXAAIm2AACJtkAAibbAAIm3AACJxsAAicdAAInHwACJyEAAickAAInJQACJyYAAicnAAInKAACJyoAAicsAAInLQACJy4AAicwAAInMQACJ3AAAidyAAIndAACJ3YAAid5AAInegACJ3sAAid8AAInfQACJ38AAieBAAInggACJ4MAAieFAAInhgACJ8UAAifHAAInyQACJ8sAAifOAAInzwACJ9AAAifRAAIn0gACJ9QAAifWAAIn1wACJ9gAAifaAAIn2wACKAAAAigkAAIoSwACKG8AAihyAAIodAACKHYAAih4AAIoegACKHwAAih9AAIogAACKI0AAiicAAIongACKKAAAiiiAAIopAACKKYAAiioAAIoqgACKLkAAii8AAIovwACKMIAAijFAAIoyAACKMsAAijOAAIo0AACKQ8AAikRAAIpEwACKRUAAikYAAIpGQACKRoAAikbAAIpHAACKR4AAikgAAIpIQACKSIAAikkAAIpJQACKWQAAilmAAIpaAACKWoAAiltAAIpbgACKW8AAilwAAIpcQACKXMAAil1AAIpdgACKXcAAil5AAIpegACKbkAAim7AAIpvQACKb8AAinCAAIpwwACKcQAAinFAAIpxgACKcgAAinKAAIpywACKcwAAinOAAIpzwACKg4AAioQAAIqEgACKhQAAioXAAIqGAACKhkAAioaAAIqGwACKh0AAiofAAIqIAACKiEAAiojAAIqJAACKmMAAiplAAIqZwACKmkAAipsAAIqbQACKm4AAipvAAIqcAACKnIAAip0AAIqdQACKnYAAip4AAIqeQACKrgAAiq6AAIqvAACKr4AAirBAAIqwgACKsMAAirEAAIqxQACKscAAirJAAIqygACKssAAirNAAIqzgACKw0AAisPAAIrEQACKxMAAisWAAIrFwACKxgAAisZAAIrGgACKxwAAiseAAIrHwACKyAAAisiAAIrIwACKywAAistAAIrLwACK24AAitwAAIrcgACK3QAAit3AAIreAACK3kAAit6AAIrewACK30AAit/AAIrgAACK4EAAiuDAAIrhAACK8MAAivFAAIrxwACK8kAAivMAAIrzQACK84AAivPAAIr0AACK9IAAivUAAIr1QACK9YAAivYAAIr2QACLBgAAiwaAAIsHQACLB8AAiwiAAIsIwACLCQAAiwlAAIsJgACLCgAAiwqAAIsKwACLCwAAiwuAAIsLwACLHoAAiydAAIsvQACLN0AAizfAAIs4QACLOMAAizlAAIs5wACLOgAAizpAAIs7AACLO0AAizvAAIs8AACLPIAAiz0AAIs9QACLPYAAiz5AAIs+gACLP8AAi0MAAItEQACLRMAAi0VAAItGgACLR0AAi0gAAItIgACLUcAAi1rAAItkgACLbYAAi25AAItuwACLb0AAi2/AAItwQACLcMAAi3EAAItxwACLdQAAi3lAAIt5wACLekAAi3rAAIt7QACLe8AAi3xAAIt8wACLfUAAi4GAAIuCQACLgwAAi4PAAIuEgACLhUAAi4YAAIuGwACLh4AAi4gAAIuXwACLmEAAi5jAAIuZQACLmgAAi5pAAIuagACLmsAAi5sAAIubgACLnAAAi5xAAIucgACLnQAAi51AAIutAACLrYAAi64AAIuugACLr0AAi6+AAIuvwACLsAAAi7BAAIuwwACLsUAAi7GAAIuxwACLskAAi7KAAIvCQACLwsAAi8OAAIvEAACLxMAAi8UAAIvFQACLxYAAi8XAAIvGQACLxsAAi8cAAIvHQACLx8AAi8gAAIvLQACLy4AAi8vAAIvMQACL3AAAi9yAAIvdAACL3YAAi95AAIvegACL3sAAi98AAIvfQACL38AAi+BAAIvggACL4MAAi+FAAIvhgACL8UAAi/HAAIvyQACL8sAAi/OAAIvzwACL9AAAi/RAAIv0gACL9QAAi/WAAIv1wACL9gAAi/aAAIv2wACMBoAAjAcAAIwHgACMCAAAjAjAAIwJAACMCUAAjAmAAIwJwACMCkAAjArAAIwLAACMC0AAjAvAAIwMAACMG8AAjBxAAIwcwACMHUAAjB4AAIweQACMHoAAjB7AAIwfAACMH4AAjCAAAIwgQACMIIAAjCEAAIwhQACMMQAAjDGAAIwyAACMMoAAjDNAAIwzgACMM8AAjDQAAIw0QACMNMAAjDVAAIw1gACMNcAAjDZAAIw2gACMP8AAjEjAAIxSgACMW4AAjFxAAIxcwACMXUAAjF3AAIxeQACMXsAAjF8AAIxfwACMYwAAjGbAAIxnQACMZ8AAjGhAAIxowACMaUAAjGnAAIxqQACMbgAAjG7AAIxvgACMcEAAjHEAAIxxwACMcoAAjHNAAIxzwACMg4AAjIQAAIyEwACMhUAAjIYAAIyGQACMhoAAjIbAAIyHAACMh4AAjIgAAIyIQACMiIAAjIkAAIyJQACMmQAAjJmAAIyaAACMmoAAjJtAAIybgACMm8AAjJwAAIycQACMnMAAjJ1AAIydgACMncAAjJ5AAIyegACMrkAAjK7AAIyvQACMr8AAjLCAAIywwACMsQAAjLFAAIyxgACMsgAAjLKAAIyywACMswAAjLOAAIyzwACMw4AAjMQAAIzEwACMxUAAjMYAAIzGQACMxoAAjMbAAIzHAACMx4AAjMgAAIzIQACMyIAAjMkAAIzJQACM2QAAjNmAAIzaAACM2oAAjNtAAIzbgACM28AAjNwAAIzcQACM3MAAjN1AAIzdgACM3cAAjN5AAIzegACM7kAAjO7AAIzvQACM78AAjPCAAIzwwACM8QAAjPFAAIzxgACM8gAAjPKAAIzywACM8wAAjPOAAIzzwACNA4AAjQQAAI0EgACNBQAAjQXAAI0GAACNBkAAjQaAAI0GwACNB0AAjQfAAI0IAACNCEAAjQjAAI0JAACNG8AAjSSAAI0sgACNNIAAjTUAAI01gACNNgAAjTaAAI03AACNN0AAjTeAAI04QACNOIAAjTkAAI05QACNOcAAjTpAAI06gACNOsAAjTuAAI07wACNPgAAjUFAAI1CgACNQwAAjUOAAI1EwACNRYAAjUZAAI1GwACNUAAAjVkAAI1iwACNa8AAjWyAAI1tAACNbYAAjW4AAI1ugACNbwAAjW9AAI1wAACNc0AAjXeAAI14AACNeIAAjXkAAI15gACNegAAjXqAAI17AACNe4AAjX/AAI2AgACNgUAAjYIAAI2CwACNg4AAjYRAAI2FAACNhcAAjYZAAI2WAACNloAAjZcAAI2XgACNmEAAjZiAAI2YwACNmQAAjZlAAI2ZwACNmkAAjZqAAI2awACNm0AAjZuAAI2rQACNq8AAjaxAAI2swACNrYAAja3AAI2uAACNrkAAja6AAI2vAACNr4AAja/AAI2wAACNsIAAjbDAAI3AgACNwQAAjcHAAI3CQACNwwAAjcNAAI3DgACNw8AAjcQAAI3EgACNxQAAjcVAAI3FgACNxgAAjcZAAI3JgACNycAAjcoAAI3KgACN2kAAjdrAAI3bQACN28AAjdyAAI3cwACN3QAAjd1AAI3dgACN3gAAjd6AAI3ewACN3wAAjd+AAI3fwACN74AAjfAAAI3wgACN8QAAjfHAAI3yAACN8kAAjfKAAI3ywACN80AAjfPAAI30AACN9EAAjfTAAI31AACOBMAAjgVAAI4FwACOBkAAjgcAAI4HQACOB4AAjgfAAI4IAACOCIAAjgkAAI4JQACOCYAAjgoAAI4KQACOGgAAjhqAAI4bAACOG4AAjhxAAI4cgACOHMAAjh0AAI4dQACOHcAAjh5AAI4egACOHsAAjh9AAI4fgACOL0AAji/AAI4wQACOMMAAjjGAAI4xwACOMgAAjjJAAI4ygACOMwAAjjOAAI4zwACONAAAjjSAAI40wACOPgAAjkcAAI5QwACOWcAAjlqAAI5bAACOW4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AAkVdAAJFXgACRV8AAkVhAAJFYwACRWQAAkVlAAJFZwACRWgAAkWnAAJFqQACRawAAkWuAAJFsQACRbIAAkWzAAJFtAACRbUAAkW3AAJFuQACRboAAkW7AAJFvQACRb4AAkXLAAJFzAACRc0AAkXPAAJGDgACRhAAAkYSAAJGFAACRhcAAkYYAAJGGQACRhoAAkYbAAJGHQACRh8AAkYgAAJGIQACRiMAAkYkAAJGYwACRmUAAkZnAAJGaQACRmwAAkZtAAJGbgACRm8AAkZwAAJGcgACRnQAAkZ1AAJGdgACRngAAkZ5AAJGuAACRroAAka8AAJGvgACRsEAAkbCAAJGwwACRsQAAkbFAAJGxwACRskAAkbKAAJGywACRs0AAkbOAAJHDQACRw8AAkcRAAJHEwACRxYAAkcXAAJHGAACRxkAAkcaAAJHHAACRx4AAkcfAAJHIAACRyIAAkcjAAJHYgACR2QAAkdmAAJHaAACR2sAAkdsAAJHbQACR24AAkdvAAJHcQACR3MAAkd0AAJHdQACR3cAAkd4AAJHnQACR8EAAkfoAAJIDAACSA8AAkgRAAJIEwACSBUAAkgXAAJIGQACSBoAAkgdAAJIKgACSDkAAkg7AAJIPQACSD8AAkhBAAJIQwACSEUAAkhHAAJIVgACSFkAAkhcAAJIXwACSGIAAkhlAAJIaAACSGsAAkhtAAJIrAACSK4AAkiwAAJIsgACSLUAAki2AAJItwACSLgAAki5AAJIuwACSL0AAki+AAJIvwACSMEAAkjCAAJJAQACSQMAAkkFAAJJBwACSQoAAkkLAAJJDAACSQ0AAkkOAAJJEAACSRIAAkkTAAJJFAACSRYAAkkXAAJJVgACSVgAAklaAAJJXAACSV8AAklgAAJJYQACSWIAAkljAAJJZQACSWcAAkloAAJJaQACSWsAAklsAAJJqwACSa0AAkmwAAJJsgACSbUAAkm2AAJJtwACSbgAAkm5AAJJuwACSb0AAkm+AAJJvwACScEAAknCAAJKBQACSikAAkpNAAJKcAACSpcAAkq3AAJK3gACSwUAAkslAAJLSQACS20AAktvAAJLcgACS3QAAkt2AAJLeAACS3sAAkt+AAJLgAACS4IAAkuFAAJLhwACS4kAAkuMAAJLjwACS5AAAkuVAAJLogACS6UAAkunAAJLqgACS60AAkuvAAJL1AACS/gAAkwfAAJMQwACTEYAAkxIAAJMSgACTEwAAkxOAAJMUAACTFEAAkxUAAJMYQACTHQAAkx2AAJMeAACTHoAAkx8AAJMfgACTIAAAkyCAAJMhAACTIYAAkyZAAJMnAACTJ8AAkyiAAJMpQACTKgAAkyrAAJMrgACTLEAAky0AAJMtgACTPUAAkz3AAJM+gACTPwAAkz/AAJNAAACTQEAAk0CAAJNAwACTQUAAk0HAAJNCAACTQkAAk0LAAJNDAACTRUAAk0WAAJNGAACTVcAAk1ZAAJNWwACTV0AAk1gAAJNYQACTWIAAk1jAAJNZAACTWYAAk1oAAJNaQACTWoAAk1sAAJNbQACTawAAk2uAAJNsQACTbMAAk22AAJNtwACTbgAAk25AAJNugACTbwAAk2+AAJNvwACTcAAAk3CAAJNwwACTcwAAk3NAAJNzwACTg4AAk4QAAJOEgACThQAAk4XAAJOGAACThkAAk4aAAJOGwACTh0AAk4fAAJOIAACTiEAAk4jAAJOJAACTmMAAk5lAAJOaAACTmoAAk5tAAJObgACTm8AAk5wAAJOcQACTnMAAk51AAJOdgACTncAAk55AAJOegACToMAAk6EAAJOhgACTsUAAk7HAAJOyQACTssAAk7OAAJOzwACTtAAAk7RAAJO0gACTtQAAk7WAAJO1wACTtgAAk7aAAJO2wACTxoAAk8cAAJPHwACTyEAAk8kAAJPJQACTyYAAk8nAAJPKAACTyoAAk8sAAJPLQACTy4AAk8wAAJPMQACTz4AAk8/AAJPQAACT0IAAk+BAAJPgwACT4UAAk+HAAJPigACT4sAAk+MAAJPjQACT44AAk+QAAJPkgACT5MAAk+UAAJPlgACT5cAAk/WAAJP2AACT9oAAk/cAAJP3wACT+AAAk/hAAJP4gACT+MAAk/lAAJP5wACT+gAAk/pAAJP6wACT+wAAk/5AAJQBAACUAYAAlAJAAJQCwACUA4AAlARAAJQHAACUB8AAlAiAAJQJQACUCgAAlArAAJQLQACUDMAAlA4AAJQPgACUIkAAlCsAAJQzAACUOwAAlDuAAJQ8AACUPIAAlD0AAJQ9wACUPgAAlD5AAJQ/AACUP0AAlD/AAJRAAACUQIAAlEEAAJRBQACUQYAAlEJAAJRCgACUQ8AAlEcAAJRIQACUSMAAlElAAJRKgACUS0AAlEwAAJRMgACUVcAAlF7AAJRogACUcYAAlHJAAJRywACUc0AAlHPAAJR0QACUdMAAlHUAAJR1wACUeQAAlH1AAJR9wACUfkAAlH7AAJR/QACUf8AAlIBAAJSAwACUgUAAlIWAAJSGQACUhwAAlIfAAJSIgACUiUAAlIoAAJSKwACUi4AAlIwAAJSbwACUnEAAlJzAAJSdQACUngAAlJ5AAJSegACUnsAAlJ8AAJSfgACUoAAAlKBAAJSggACUoQAAlKFAAJSxAACUsYAAlLIAAJSygACUs0AAlLOAAJSzwACUtAAAlLRAAJS0wACUtUAAlLWAAJS1wACUtkAAlLaAAJTGQACUxsAAlMeAAJTIAACUyMAAlMkAAJTJQACUyYAAlMnAAJTKQACUysAAlMsAAJTLQACUy8AAlMwAAJTPQACUz4AAlM/AAJTQQACU4AAAlOCAAJThAACU4YAAlOJAAJTigACU4sAAlOMAAJTjQACU48AAlORAAJTkgACU5MAAlOVAAJTlgACU9UAAlPXAAJT2QACU9sAAlPeAAJT3wACU+AAAlPhAAJT4gACU+QAAlPmAAJT5wACU+gAAlPqAAJT6wACVCoAAlQsAAJULgACVDAAAlQzAAJUNAACVDUAAlQ2AAJUNwACVDkAAlQ7AAJUPAACVD0AAlQ/AAJUQAACVH8AAlSBAAJUgwACVIUAAlSIAAJUiQACVIoAAlSLAAJUjAACVI4AAlSQAAJUkQACVJIAAlSUAAJUlQACVNQAAlTWAAJU2AACVNoAAlTdAAJU3gACVN8AAlTgAAJU4QACVOMAAlTlAAJU5gACVOcAAlTpAAJU6gACVQ8AAlUzAAJVWgACVX4AAlWBAAJVgwACVYUAAlWHAAJViQACVYsAAlWMAAJVjwACVZwAAlWrAAJVrQACVa8AAlWxAAJVswACVbUAAlW3AAJVuQACVcgAAlXLAAJVzgACVdEAAlXUAAJV1wACVdoAAlXdAAJV3wACVh4AAlYgAAJWIwACViUAAlYoAAJWKQACVioAAlYrAAJWLAACVi4AAlYwAAJWMQACVjIAAlY0AAJWNQACVnQAAlZ2AAJWeAACVnoAAlZ9AAJWfgACVn8AAlaAAAJWgQACVoMAAlaFAAJWhgACVocAAlaJAAJWigACVskAAlbLAAJWzQACVs8AAlbSAAJW0wACVtQAAlbVAAJW1gACVtgAAlbaAAJW2wACVtwAAlbeAAJW3wACVx4AAlcgAAJXIwACVyUAAlcoAAJXKQACVyoAAlcrAAJXLAACVy4AAlcwAAJXMQACVzIAAlc0AAJXNQACV3QAAld2AAJXeAACV3oAAld9AAJXfgACV38AAleAAAJXgQACV4MAAleFAAJXhgACV4cAAleJAAJXigACV8kAAlfLAAJXzQACV88AAlfSAAJX0wACV9QAAlfVAAJX1gACV9gAAlfaAAJX2wACV9wAAlfeAAJX3wACWB4AAlggAAJYIgACWCQAAlgnAAJYKAACWCkAAlgqAAJYKwACWC0AAlgvAAJYMAACWDEAAlgzAAJYNAACWH8AAliiAAJYwgACWOIAAljkAAJY5gACWOgAAljqAAJY7QACWO4AAljvAAJY8gACWPMAAlj1AAJY9gACWPgAAlj7AAJY/AACWP0AAlkAAAJZAQACWQYAAlkTAAJZGAACWRoAAlkcAAJZIQACWSQAAlknAAJZKQACWU4AAllyAAJZmQACWb0AAlnAAAJZwgACWcQAAlnGAAJZyAACWcoAAlnLAAJZzgACWdsAAlnsAAJZ7gACWfAAAlnyAAJZ9AACWfYAAln4AAJZ+gACWfwAAloNAAJaEAACWhMAAloWAAJaGQACWhwAAlofAAJaIgACWiUAAlonAAJaZgACWmgAAlpqAAJabAACWm8AAlpwAAJacQACWnIAAlpzAAJadQACWncAAlp4AAJaeQACWnsAAlp8AAJauwACWr0AAlq/AAJawQACWsQAAlrFAAJaxgACWscAAlrIAAJaygACWswAAlrNAAJazgACWtAAAlrRAAJbEAACWxIAAlsVAAJbFwACWxoAAlsbAAJbHAACWx0AAlseAAJbIAACWyIAAlsjAAJbJAACWyYAAlsnAAJbNAACWzUAAls2AAJbOAACW3cAAlt5AAJbewACW30AAluAAAJbgQACW4IAAluDAAJbhAACW4YAAluIAAJbiQACW4oAAluMAAJbjQACW8wAAlvOAAJb0AACW9IAAlvVAAJb1gACW9cAAlvYAAJb2QACW9sAAlvdAAJb3gACW98AAlvhAAJb4gACXCEAAlwjAAJcJQACXCcAAlwqAAJcKwACXCwAAlwtAAJcLgACXDAAAlwyAAJcMwACXDQAAlw2AAJcNwACXHYAAlx4AAJcegACXHwAAlx/AAJcgAACXIEAAlyCAAJcgwACXIUAAlyHAAJciAACXIkAAlyLAAJcjAACXMsAAlzNAAJczwACXNEAAlzUAAJc1QACXNYAAlzXAAJc2AACXNoAAlzcAAJc3QACXN4AAlzgAAJc4QACXQYAAl0qAAJdUQACXXUAAl14AAJdegACXXwAAl1+AAJdgAACXYIAAl2DAAJdhgACXZMAAl2iAAJdpAACXaYAAl2oAAJdqgACXawAAl2uAAJdsAACXb8AAl3CAAJdxQACXcgAAl3LAAJdzgACXdEAAl3UAAJd1gACXhUAAl4XAAJeGgACXhwAAl4fAAJeIAACXiEAAl4iAAJeIwACXiUAAl4nAAJeKAACXikAAl4rAAJeLAACXmsAAl5tAAJebwACXnEAAl50AAJedQACXnYAAl53AAJeeAACXnoAAl58AAJefQACXn4AAl6AAAJegQACXsAAAl7CAAJexAACXsYAAl7JAAJeygACXssAAl7MAAJezQACXs8AAl7RAAJe0gACXtMAAl7VAAJe1gACXxUAAl8XAAJfGgACXxwAAl8fAAJfIAACXyEAAl8iAAJfIwACXyUAAl8nAAJfKAACXykAAl8rAAJfLAACX2sAAl9tAAJfbwACX3EAAl90AAJfdQACX3YAAl93AAJfeAACX3oAAl98AAJffQACX34AAl+AAAJfgQACX8AAAl/CAAJfxAACX8YAAl/JAAJfygACX8sAAl/MAAJfzQACX88AAl/RAAJf0gACX9MAAl/VAAJf1gACYBUAAmAXAAJgGQACYBsAAmAeAAJgHwACYCAAAmAhAAJgIgACYCQAAmAmAAJgJwACYCgAAmAqAAJgKwACYHYAAmCZAAJguQACYNkAAmDbAAJg3QACYN8AAmDhAAJg5AACYOUAAmDmAAJg6QACYOoAAmDsAAJg7QACYO8AAmDxAAJg8gACYPMAAmD2AAJg9wACYPwAAmEJAAJhDgACYRAAAmESAAJhFwACYRoAAmEdAAJhHwACYUQAAmFoAAJhjwACYbMAAmG2AAJhuAACYboAAmG8AAJhvgACYcAAAmHBAAJhxAACYdEAAmHiAAJh5AACYeYAAmHoAAJh6gACYewAAmHuAAJh8AACYfIAAmIDAAJiBgACYgkAAmIMAAJiDwACYhIAAmIVAAJiGAACYhsAAmIdAAJiXAACYl4AAmJgAAJiYgACYmUAAmJmAAJiZwACYmgAAmJpAAJiawACYm0AAmJuAAJibwACYnEAAmJyAAJisQACYrMAAmK1AAJitwACYroAAmK7AAJivAACYr0AAmK+AAJiwAACYsIAAmLDAAJixAACYsYAAmLHAAJjBgACYwgAAmMLAAJjDQACYxAAAmMRAAJjEgACYxMAAmMUAAJjFgACYxgAAmMZAAJjGgACYxwAAmMdAAJjKgACYysAAmMsAAJjLgACY20AAmNvAAJjcQACY3MAAmN2AAJjdwACY3gAAmN5AAJjegACY3wAAmN+AAJjfwACY4AAAmOCAAJjgwACY8IAAmPEAAJjxgACY8gAAmPLAAJjzAACY80AAmPOAAJjzwACY9EAAmPTAAJj1AACY9UAAmPXAAJj2AACZBcAAmQZAAJkGwACZB0AAmQgAAJkIQACZCIAAmQjAAJkJAACZCYAAmQoAAJkKQACZCoAAmQsAAJkLQACZGwAAmRuAAJkcAACZHIAAmR1AAJkdgACZHcAAmR4AAJkeQACZHsAAmR9AAJkfgACZH8AAmSBAAJkggACZMEAAmTDAAJkxQACZMcAAmTKAAJkywACZMwAAmTNAAJkzgACZNAAAmTSAAJk0wACZNQAAmTWAAJk1wACZPwAAmUgAAJlRwACZWsAAmVuAAJlcAACZXIAAmV0AAJldgACZXgAAmV5AAJlfAACZYkAAmWYAAJlmgACZZwAAmWeAAJloAACZaIAAmWkAAJlpgACZbUAAmW4AAJluwACZb4AAmXBAAJlxAACZccAAmXKAAJlzAACZgsAAmYNAAJmDwACZhEAAmYUAAJmFQACZhYAAmYXAAJmGAACZhoAAmYcAAJmHQACZh4AAmYgAAJmIQACZmAAAmZiAAJmZAACZmYAAmZpAAJmagACZmsAAmZsAAJmbQACZm8AAmZxAAJmcgACZnMAAmZ1AAJmdgACZrUAAma3AAJmuQACZrsAAma+AAJmvwACZsAAAmbBAAJmwgACZsQAAmbGAAJmxwACZsgAAmbKAAJmywACZwoAAmcMAAJnDgACZxAAAmcTAAJnFAACZxUAAmcWAAJnFwACZxkAAmcbAAJnHAACZx0AAmcfAAJnIAACZ18AAmdhAAJnYwACZ2UAAmdoAAJnaQACZ2oAAmdrAAJnbAACZ24AAmdwAAJncQACZ3IAAmd0AAJndQACZ7QAAme2AAJnuAACZ7oAAme9AAJnvgACZ78AAmfAAAJnwQACZ8MAAmfFAAJnxgACZ8cAAmfJAAJnygACaAkAAmgLAAJoDQACaA8AAmgSAAJoEwACaBQAAmgVAAJoFgACaBgAAmgaAAJoGwACaBwAAmgeAAJoHwACaGoAAmiNAAJorQACaM0AAmjPAAJo0QACaNMAAmjVAAJo2AACaNkAAmjaAAJo3QACaN4AAmjgAAJo4QACaOMAAmjmAAJo5wACaOgAAmjrAAJo7AACaPEAAmj+AAJpAwACaQUAAmkHAAJpDAACaQ8AAmkSAAJpFAACaTkAAmldAAJphAACaagAAmmrAAJprQACaa8AAmmxAAJpswACabUAAmm2AAJpuQACacYAAmnXAAJp2QACadsAAmndAAJp3wACaeEAAmnjAAJp5QACaecAAmn4AAJp+wACaf4AAmoBAAJqBAACagcAAmoKAAJqDQACahAAAmoSAAJqUQACalMAAmpVAAJqVwACaloAAmpbAAJqXAACal0AAmpeAAJqYAACamIAAmpjAAJqZAACamYAAmpnAAJqpgACaqgAAmqqAAJqrAACaq8AAmqwAAJqsQACarIAAmqzAAJqtQACarcAAmq4AAJquQACarsAAmq8AAJq+wACav0AAmsAAAJrAgACawUAAmsGAAJrBwACawgAAmsJAAJrCwACaw0AAmsOAAJrDwACaxEAAmsSAAJrHwACayAAAmshAAJrIwACa2IAAmtkAAJrZgACa2gAAmtrAAJrbAACa20AAmtuAAJrbwACa3EAAmtzAAJrdAACa3UAAmt3AAJreAACa7cAAmu5AAJruwACa70AAmvAAAJrwQACa8IAAmvDAAJrxAACa8YAAmvIAAJryQACa8oAAmvMAAJrzQACbAwAAmwOAAJsEAACbBIAAmwVAAJsFgACbBcAAmwYAAJsGQACbBsAAmwdAAJsHgACbB8AAmwhAAJsIgACbGEAAmxjAAJsZQACbGcAAmxqAAJsawACbGwAAmxtAAJsbgACbHAAAmxyAAJscwACbHQAAmx2AAJsdwACbLYAAmy4AAJsugACbLwAAmy/AAJswAACbMEAAmzCAAJswwACbMUAAmzHAAJsyAACbMkAAmzLAAJszAACbPEAAm0VAAJtPAACbWAAAm1jAAJtZQACbWcAAm1pAAJtawACbW0AAm1uAAJtcQACbX4AAm2NAAJtjwACbZEAAm2TAAJtlQACbZcAAm2ZAAJtmwACbaoAAm2tAAJtsAACbbMAAm22AAJtuQACbbwAAm2/AAJtwQACbgAAAm4CAAJuBAACbgYAAm4JAAJuCgACbgsAAm4MAAJuDQACbg8AAm4RAAJuEgACbhMAAm4VAAJuFgACblUAAm5XAAJuWQACblsAAm5eAAJuXwACbmAAAm5hAAJuYgACbmQAAm5mAAJuZwACbmgAAm5qAAJuawACbqoAAm6sAAJurgACbrAAAm6zAAJutAACbrUAAm62AAJutwACbrkAAm67AAJuvAACbr0AAm6/AAJuwAACbv8AAm8BAAJvBAACbwYAAm8JAAJvCgACbwsAAm8MAAJvDQACbw8AAm8RAAJvEgACbxMAAm8VAAJvFgACb1UAAm9XAAJvWQACb1sAAm9eAAJvXwACb2AAAm9hAAJvYgACb2QAAm9mAAJvZwACb2gAAm9qAAJvawACb6oAAm+sAAJvrgACb7AAAm+zAAJvtAACb7UAAm+2AAJvtwACb7kAAm+7AAJvvAACb70AAm+/AAJvwAACb/8AAnABAAJwBAACcAYAAnAJAAJwCgACcAsAAnAMAAJwDQACcA8AAnARAAJwEgACcBMAAnAVAAJwFgACcGEAAnCEAAJwpAACcMQAAnDGAAJwyAACcMoAAnDMAAJwzwACcNAAAnDRAAJw1AACcNUAAnDXAAJw2AACcNoAAnDdAAJw3gACcN8AAnDiAAJw4wACcOwAAnD5AAJw/gACcQAAAnECAAJxBwACcQoAAnENAAJxDwACcTQAAnFYAAJxfwACcaMAAnGmAAJxqAACcaoAAnGsAAJxrgACcbAAAnGxAAJxtAACccEAAnHSAAJx1AACcdYAAnHYAAJx2gACcdwAAnHeAAJx4AACceIAAnHzAAJx9gACcfkAAnH8AAJx/wACcgIAAnIFAAJyCAACcgsAAnINAAJyTAACck4AAnJQAAJyUgACclUAAnJWAAJyVwACclgAAnJZAAJyWwACcl0AAnJeAAJyXwACcmEAAnJiAAJyoQACcqMAAnKlAAJypwACcqoAAnKrAAJyrAACcq0AAnKuAAJysAACcrIAAnKzAAJytAACcrYAAnK3AAJy9gACcvgAAnL7AAJy/QACcwAAAnMBAAJzAgACcwMAAnMEAAJzBgACcwgAAnMJAAJzCgACcwwAAnMNAAJzGgACcxsAAnMcAAJzHgACc10AAnNfAAJzYQACc2MAAnNmAAJzZwACc2gAAnNpAAJzagACc2wAAnNuAAJzbwACc3AAAnNyAAJzcwACc7IAAnO0AAJztgACc7gAAnO7AAJzvAACc70AAnO+AAJzvwACc8EAAnPDAAJzxAACc8UAAnPHAAJzyAACdAcAAnQJAAJ0CwACdA0AAnQQAAJ0EQACdBIAAnQTAAJ0FAACdBYAAnQYAAJ0GQACdBoAAnQcAAJ0HQACdFwAAnReAAJ0YAACdGIAAnRlAAJ0ZgACdGcAAnRoAAJ0aQACdGsAAnRtAAJ0bgACdG8AAnRxAAJ0cgACdLEAAnSzAAJ0tQACdLcAAnS6AAJ0uwACdLwAAnS9AAJ0vgACdMAAAnTCAAJ0wwACdMQAAnTGAAJ0xwACdOwAAnUQAAJ1NwACdVsAAnVeAAJ1YAACdWIAAnVkAAJ1ZgACdWgAAnVpAAJ1bAACdXkAAnWIAAJ1igACdYwAAnWOAAJ1kAACdZIAAnWUAAJ1lgACdaUAAnWoAAJ1qwACda4AAnWxAAJ1tAACdbcAAnW6AAJ1vAACdfsAAnX9AAJ2AAACdgIAAnYFAAJ2BgACdgcAAnYIAAJ2CQACdgsAAnYNAAJ2DgACdg8AAnYRAAJ2EgACdlEAAnZTAAJ2VQACdlcAAnZaAAJ2WwACdlwAAnZdAAJ2XgACdmAAAnZiAAJ2YwACdmQAAnZmAAJ2ZwACdqYAAnaoAAJ2qgACdqwAAnavAAJ2sAACdrEAAnayAAJ2swACdrUAAna3AAJ2uAACdrkAAna7AAJ2vAACdvsAAnb9AAJ3AAACdwIAAncFAAJ3BgACdwcAAncIAAJ3CQACdwsAAncNAAJ3DgACdw8AAncRAAJ3EgACd1EAAndTAAJ3VQACd1cAAndaAAJ3WwACd1wAAnddAAJ3XgACd2AAAndiAAJ3YwACd2QAAndmAAJ3ZwACd6YAAneoAAJ3qgACd6wAAnevAAJ3sAACd7EAAneyAAJ3swACd7UAAne3AAJ3uAACd7kAAne7AAJ3vAACd/sAAnf9AAJ3/wACeAEAAngEAAJ4BQACeAYAAngHAAJ4CAACeAoAAngMAAJ4DQACeA4AAngQAAJ4EQACeBoAAngbAAJ4HQACeFwAAnheAAJ4YAACeGIAAnhlAAJ4ZgACeGcAAnhoAAJ4aQACeGsAAnhtAAJ4bgACeG8AAnhxAAJ4cgACeLEAAnizAAJ4tQACeLcAAni6AAJ4uwACeLwAAni9AAJ4vgACeMAAAnjCAAJ4wwACeMQAAnjGAAJ4xwACeQYAAnkIAAJ5CwACeQ0AAnkQAAJ5EQACeRIAAnkTAAJ5FAACeRYAAnkYAAJ5GQACeRoAAnkcAAJ5HQACeSYAAnkpAAJ5LAACeS4AAnkzAAJ5PAACeVUAAnleAAJ5dwACeY4AAnmgAAJ5vQACecAAAnnCAAJ5xAACecYAAnnJAAJ5ywACedAAAnoPAAJ6EQACehMAAnoVAAJ6FwACehgAAnoZAAJ6GgACehsAAnodAAJ6HwACeiAAAnohAAJ6IwACeiQAAnpjAAJ6ZQACemcAAnppAAJ6awACemwAAnptAAJ6bgACem8AAnpxAAJ6cwACenQAAnp1AAJ6dwACengAAnq3AAJ6uQACersAAnq9AAJ6vwACesAAAnrBAAJ6wgACesMAAnrFAAJ6xwACesgAAnrJAAJ6ywACeswAAnrVAAJ62AACetsAAnrdAAJ7IAACe0QAAntoAAJ7iwACe7IAAnvSAAJ7+QACfCAAAnxAAAJ8ZAACfIgAAnyKAAJ8jQACfI8AAnyRAAJ8kwACfJYAAnyZAAJ8mwACfJ0AAnygAAJ8ogACfKQAAnynAAJ8qgACfKsAAnywAAJ8vQACfMAAAnzCAAJ8xQACfMgAAnzKAAJ87wACfRMAAn06AAJ9XgACfWEAAn1jAAJ9ZQACfWcAAn1pAAJ9awACfWwAAn1vAAJ9fAACfY8AAn2RAAJ9kwACfZUAAn2XAAJ9mQACfZsAAn2dAAJ9nwACfaEAAn20AAJ9twACfboAAn29AAJ9wAACfcMAAn3GAAJ9yQACfcwAAn3PAAJ90QACfhAAAn4SAAJ+FQACfhcAAn4aAAJ+GwACfhwAAn4dAAJ+HgACfiAAAn4iAAJ+IwACfiQAAn4mAAJ+JwACfjAAAn4xAAJ+MwACfnIAAn50AAJ+dgACfngAAn57AAJ+fAACfn0AAn5+AAJ+fwACfoEAAn6DAAJ+hAACfoUAAn6HAAJ+iAACfscAAn7JAAJ+zAACfs4AAn7RAAJ+0gACftMAAn7UAAJ+1QACftcAAn7ZAAJ+2gACftsAAn7dAAJ+3gACfucAAn7oAAJ+6gACfykAAn8rAAJ/LQACfy8AAn8yAAJ/MwACfzQAAn81AAJ/NgACfzgAAn86AAJ/OwACfzwAAn8+AAJ/PwACf34AAn+AAAJ/gwACf4UAAn+IAAJ/iQACf4oAAn+LAAJ/jAACf44AAn+QAAJ/kQACf5IAAn+UAAJ/lQACf54AAn+fAAJ/oQACf+AAAn/iAAJ/5AACf+YAAn/pAAJ/6gACf+sAAn/sAAJ/7QACf+8AAn/xAAJ/8gACf/MAAn/1AAJ/9gACgDUAAoA3AAKAOgACgDwAAoA/AAKAQAACgEEAAoBCAAKAQwACgEUAAoBHAAKASAACgEkAAoBLAAKATAACgFkAAoBaAAKAWwACgF0AAoCcAAKAngACgKAAAoCiAAKApQACgKYAAoCnAAKAqAACgKkAAoCrAAKArQACgK4AAoCvAAKAsQACgLIAAoDxAAKA8wACgPUAAoD3AAKA+gACgPsAAoD8AAKA/QACgP4AAoEAAAKBAgACgQMAAoEEAAKBBgACgQcAAoEUAAKBHQACgSAAAoEjAAKBJQACgSgAAoExAAKBNAACgTcAAoE6AAKBPQACgT8AAoFFAAKBVwACgWQAAoGvAAKB0gACgfIAAoISAAKCFAACghYAAoIYAAKCGgACgh0AAoIeAAKCHwACgiIAAoIjAAKCJQACgiYAAoIoAAKCKwACgiwAAoItAAKCMAACgjEAAoI2AAKCQwACgkgAAoJKAAKCTAACglEAAoJUAAKCVwACglkAAoJ+AAKCogACgskAAoLtAAKC8AACgvIAAoL0AAKC9gACgvgAAoL6AAKC+wACgv4AAoMLAAKDHAACgx4AAoMgAAKDIgACgyQAAoMmAAKDKAACgyoAAoMsAAKDPQACg0AAAoNDAAKDRgACg0kAAoNMAAKDTwACg1IAAoNVAAKDVwACg5YAAoOYAAKDmgACg5wAAoOfAAKDoAACg6EAAoOiAAKDowACg6UAAoOnAAKDqAACg6kAAoOrAAKDrAACg+sAAoPtAAKD7wACg/EAAoP0AAKD9QACg/YAAoP3AAKD+AACg/oAAoP8AAKD/QACg/4AAoQAAAKEAQAChEAAAoRCAAKERQAChEcAAoRKAAKESwAChEwAAoRNAAKETgAChFAAAoRSAAKEUwAChFQAAoRWAAKEVwAChGQAAoRlAAKEZgAChGgAAoSnAAKEqQAChKsAAoStAAKEsAAChLEAAoSyAAKEswAChLQAAoS2AAKEuAAChLkAAoS6AAKEvAAChL0AAoT8AAKE/gAChQAAAoUCAAKFBQAChQYAAoUHAAKFCAAChQkAAoULAAKFDQAChQ4AAoUPAAKFEQAChRIAAoVRAAKFUwAChVUAAoVXAAKFWgAChVsAAoVcAAKFXQAChV4AAoVgAAKFYgAChWMAAoVkAAKFZgAChWcAAoWmAAKFqAAChaoAAoWsAAKFrwAChbAAAoWxAAKFsgAChbMAAoW1AAKFtwAChbgAAoW5AAKFuwAChbwAAoX7AAKF/QAChf8AAoYBAAKGBAAChgUAAoYGAAKGBwAChggAAoYKAAKGDAAChg0AAoYOAAKGEAAChhEAAoY2AAKGWgAChoEAAoalAAKGqAAChqoAAoasAAKGrgAChrAAAoayAAKGswAChrYAAobDAAKG0gAChtQAAobWAAKG2AAChtoAAobcAAKG3gAChuAAAobvAAKG8gAChvUAAob4AAKG+wAChv4AAocBAAKHBAAChwYAAodFAAKHRwACh0kAAodLAAKHTgACh08AAodQAAKHUQACh1IAAodUAAKHVgACh1cAAodYAAKHWgACh1sAAoeaAAKHnAACh54AAoegAAKHowACh6QAAoelAAKHpgACh6cAAoepAAKHqwACh6wAAoetAAKHrwACh7AAAofvAAKH8QACh/MAAof1AAKH+AACh/kAAof6AAKH+wACh/wAAof+AAKIAAACiAEAAogCAAKIBAACiAUAAohEAAKIRgACiEgAAohKAAKITQACiE4AAohPAAKIUAACiFEAAohTAAKIVQACiFYAAohXAAKIWQACiFoAAoiZAAKImwACiJ0AAoifAAKIogACiKMAAoikAAKIpQACiKYAAoioAAKIqgACiKsAAoisAAKIrgACiK8AAojuAAKI8AACiPIAAoj0AAKI9wACiPgAAoj5AAKI+gACiPsAAoj9AAKI/wACiQAAAokBAAKJAwACiQQAAolDAAKJRQACiUcAAolJAAKJTAACiU0AAolOAAKJTwACiVAAAolSAAKJVAACiVUAAolWAAKJWAACiVkAAomkAAKJxwACiecAAooHAAKKCQACigsAAooNAAKKDwACihIAAooTAAKKFAACihcAAooYAAKKGgACihsAAoodAAKKIAACiiEAAooiAAKKJQACiiYAAoovAAKKPAACikEAAopDAAKKRQACikoAAopNAAKKUAACilIAAop3AAKKmwACisIAAormAAKK6QACiusAAortAAKK7wACivEAAorzAAKK9AACivcAAosEAAKLFQACixcAAosZAAKLGwACix0AAosfAAKLIQACiyMAAoslAAKLNgACizkAAos8AAKLPwACi0IAAotFAAKLSAACi0sAAotOAAKLUAACi48AAouRAAKLkwACi5UAAouYAAKLmQACi5oAAoubAAKLnAACi54AAougAAKLoQACi6IAAoukAAKLpQACi+QAAovmAAKL6AACi+oAAovtAAKL7gACi+8AAovwAAKL8QACi/MAAov1AAKL9gACi/cAAov5AAKL+gACjDkAAow7AAKMPgACjEAAAoxDAAKMRAACjEUAAoxGAAKMRwACjEkAAoxLAAKMTAACjE0AAoxPAAKMUAACjF0AAoxeAAKMXwACjGEAAoygAAKMogACjKQAAoymAAKMqQACjKoAAoyrAAKMrAACjK0AAoyvAAKMsQACjLIAAoyzAAKMtQACjLYAAoz1AAKM9wACjPkAAoz7AAKM/gACjP8AAo0AAAKNAQACjQIAAo0EAAKNBgACjQcAAo0IAAKNCgACjQsAAo1KAAKNTAACjU4AAo1QAAKNUwACjVQAAo1VAAKNVgACjVcAAo1ZAAKNWwACjVwAAo1dAAKNXwACjWAAAo2fAAKNoQACjaMAAo2lAAKNqAACjakAAo2qAAKNqwACjawAAo2uAAKNsAACjbEAAo2yAAKNtAACjbUAAo30AAKN9gACjfgAAo36AAKN/QACjf4AAo3/AAKOAAACjgEAAo4DAAKOBQACjgYAAo4HAAKOCQACjgoAAo4vAAKOUwACjnoAAo6eAAKOoQACjqMAAo6lAAKOpwACjqkAAo6rAAKOrAACjq8AAo68AAKOywACjs0AAo7PAAKO0QACjtMAAo7VAAKO1wACjtkAAo7oAAKO6wACju4AAo7xAAKO9AACjvcAAo76AAKO/QACjv8AAo8+AAKPQAACj0IAAo9EAAKPRwACj0gAAo9JAAKPSgACj0sAAo9NAAKPTwACj1AAAo9RAAKPUwACj1QAAo+TAAKPlQACj5cAAo+ZAAKPnAACj50AAo+eAAKPnwACj6AAAo+iAAKPpAACj6UAAo+mAAKPqAACj6kAAo/oAAKP6gACj+wAAo/uAAKP8QACj/IAAo/zAAKP9AACj/UAAo/3AAKP+QACj/oAAo/7AAKP/QACj/4AApA9AAKQPwACkEIAApBEAAKQRwACkEgAApBJAAKQSgACkEsAApBNAAKQTwACkFAAApBRAAKQUwACkFQAApCTAAKQlQACkJcAApCZAAKQnAACkJ0AApCeAAKQnwACkKAAApCiAAKQpAACkKUAApCmAAKQqAACkKkAApDoAAKQ6gACkOwAApDuAAKQ8QACkPIAApDzAAKQ9AACkPUAApD3AAKQ+QACkPoAApD7AAKQ/QACkP4AApE9AAKRPwACkUEAApFDAAKRRgACkUcAApFIAAKRSQACkUoAApFMAAKRTgACkU8AApFQAAKRUgACkVMAApGeAAKRwQACkeEAApIBAAKSAwACkgUAApIHAAKSCQACkgwAApINAAKSDgACkhEAApISAAKSFAACkhUAApIXAAKSGQACkhoAApIbAAKSHgACkh8AApIkAAKSMQACkjYAApI4AAKSOgACkj8AApJCAAKSRQACkkcAApJsAAKSkAACkrcAApLbAAKS3gACkuAAApLiAAKS5AACkuYAApLoAAKS6QACkuwAApL5AAKTCgACkwwAApMOAAKTEAACkxIAApMUAAKTFgACkxgAApMaAAKTKwACky4AApMxAAKTNAACkzcAApM6AAKTPQACk0AAApNDAAKTRQACk4QAApOGAAKTiAACk4oAApONAAKTjgACk48AApOQAAKTkQACk5MAApOVAAKTlgACk5cAApOZAAKTmgACk9kAApPbAAKT3QACk98AApPiAAKT4wACk+QAApPlAAKT5gACk+gAApPqAAKT6wACk+wAApPuAAKT7wAClC4AApQwAAKUMwAClDUAApQ4AAKUOQAClDoAApQ7AAKUPAAClD4AApRAAAKUQQAClEIAApREAAKURQAClFIAApRTAAKUVAAClFYAApSVAAKUlwAClJkAApSbAAKUngAClJ8AApSgAAKUoQAClKIAApSkAAKUpgAClKcAApSoAAKUqgAClKsAApTqAAKU7AAClO4AApTwAAKU8wAClPQAApT1AAKU9gAClPcAApT5AAKU+wAClPwAApT9AAKU/wAClQAAApU/AAKVQQAClUMAApVFAAKVSAAClUkAApVKAAKVSwAClUwAApVOAAKVUAAClVEAApVSAAKVVAAClVUAApWUAAKVlgAClZgAApWaAAKVnQAClZ4AApWfAAKVoAAClaEAApWjAAKVpQAClaYAApWnAAKVqQAClaoAApXpAAKV6wACle0AApXvAAKV8gAClfMAApX0AAKV9QAClfYAApX4AAKV+gAClfsAApX8AAKV/gAClf8AApYkAAKWSAAClm8AApaTAAKWlgAClpgAApaaAAKWnAAClp4AApagAAKWoQAClqQAApaxAAKWwAAClsIAApbEAAKWxgAClsgAApbKAAKWzAACls4AApbdAAKW4AACluMAApbmAAKW6QACluwAApbvAAKW8gAClvQAApczAAKXNQAClzcAApc5AAKXPAAClz0AApc+AAKXPwACl0AAApdCAAKXRAACl0UAApdGAAKXSAACl0kAApeIAAKXigACl4wAApeOAAKXkQACl5IAApeTAAKXlAACl5UAApeXAAKXmQACl5oAApebAAKXnQACl54AApfdAAKX3wACl+EAApfjAAKX5gACl+cAApfoAAKX6QACl+oAApfsAAKX7gACl+8AApfwAAKX8gACl/MAApgyAAKYNAACmDYAApg4AAKYOwACmDwAApg9AAKYPgACmD8AAphBAAKYQwACmEQAAphFAAKYRwACmEgAApiHAAKYiQACmIsAApiNAAKYkAACmJEAApiSAAKYkwACmJQAApiWAAKYmAACmJkAApiaAAKYnAACmJ0AApjcAAKY3gACmOAAApjiAAKY5QACmOYAApjnAAKY6AACmOkAApjrAAKY7QACmO4AApjvAAKY8QACmPIAApkxAAKZMwACmTUAApk3AAKZOgACmTsAApk8AAKZPQACmT4AAplAAAKZQgACmUMAAplEAAKZRgACmUcAApmSAAKZtQACmdUAApn1AAKZ9wACmfkAApn7AAKZ/QACmgAAApoBAAKaAgACmgUAApoGAAKaCAACmgkAApoLAAKaDgACmg8AApoQAAKaEwACmhQAApoZAAKaJgACmisAApotAAKaLwACmjQAApo3AAKaOgACmjwAApphAAKahQACmqwAAprQAAKa0wACmtUAAprXAAKa2QACmtsAAprdAAKa3gACmuEAApruAAKa/wACmwEAApsDAAKbBQACmwcAApsJAAKbCwACmw0AApsPAAKbIAACmyMAApsmAAKbKQACmywAApsvAAKbMgACmzUAAps4AAKbOgACm3kAApt7AAKbfQACm38AApuCAAKbgwACm4QAApuFAAKbhgACm4gAApuKAAKbiwACm4wAApuOAAKbjwACm84AApvQAAKb0gACm9QAApvXAAKb2AACm9kAApvaAAKb2wACm90AApvfAAKb4AACm+EAApvjAAKb5AACnCMAApwlAAKcKAACnCoAApwtAAKcLgACnC8AApwwAAKcMQACnDMAApw1AAKcNgACnDcAApw5AAKcOgACnEcAApxIAAKcSQACnEsAApyKAAKcjAACnI4AApyQAAKckwACnJQAApyVAAKclgACnJcAApyZAAKcmwACnJwAApydAAKcnwACnKAAApzfAAKc4QACnOMAApzlAAKc6AACnOkAApzqAAKc6wACnOwAApzuAAKc8AACnPEAApzyAAKc9AACnPUAAp00AAKdNgACnTgAAp06AAKdPQACnT4AAp0/AAKdQAACnUEAAp1DAAKdRQACnUYAAp1HAAKdSQACnUoAAp2JAAKdiwACnY0AAp2PAAKdkgACnZMAAp2UAAKdlQACnZYAAp2YAAKdmgACnZsAAp2cAAKdngACnZ8AAp3eAAKd4AACneIAAp3kAAKd5wACnegAAp3pAAKd6gACnesAAp3tAAKd7wACnfAAAp3xAAKd8wACnfQAAp4ZAAKePQACnmQAAp6IAAKeiwACno0AAp6PAAKekQACnpMAAp6VAAKelgACnpkAAp6mAAKetQACnrcAAp65AAKeuwACnr0AAp6/AAKewQACnsMAAp7SAAKe1QACntgAAp7bAAKe3gACnuEAAp7kAAKe5wACnukAAp8oAAKfKgACnywAAp8uAAKfMQACnzIAAp8zAAKfNAACnzUAAp83AAKfOQACnzoAAp87AAKfPQACnz4AAp99AAKffwACn4EAAp+DAAKfhgACn4cAAp+IAAKfiQACn4oAAp+MAAKfjgACn48AAp+QAAKfkgACn5MAAp/SAAKf1AACn9YAAp/YAAKf2wACn9wAAp/dAAKf3gACn98AAp/hAAKf4wACn+QAAp/lAAKf5wACn+gAAqAnAAKgKQACoCwAAqAuAAKgMQACoDIAAqAzAAKgNAACoDUAAqA3AAKgOQACoDoAAqA7AAKgPQACoD4AAqB9AAKgfwACoIEAAqCDAAKghgACoIcAAqCIAAKgiQACoIoAAqCMAAKgjgACoI8AAqCQAAKgkgACoJMAAqDSAAKg1AACoNYAAqDYAAKg2wACoNwAAqDdAAKg3gACoN8AAqDhAAKg4wACoOQAAqDlAAKg5wACoOgAAqEnAAKhKQACoSsAAqEtAAKhMAACoTEAAqEyAAKhMwACoTQAAqE2AAKhOAACoTkAAqE6AAKhPAACoT0AAqFGAAKhRwACoUkAAqGMAAKhsAACodQAAqH3AAKiHgACoj4AAqJlAAKijAACoqwAAqLQAAKi9AACovYAAqL5AAKi+wACov0AAqL/AAKjAgACowUAAqMHAAKjCQACowwAAqMOAAKjEAACoxMAAqMWAAKjFwACoyAAAqMtAAKjMAACozIAAqM1AAKjOAACozoAAqNfAAKjgwACo6oAAqPOAAKj0QACo9MAAqPVAAKj1wACo9kAAqPbAAKj3AACo98AAqPsAAKj/wACpAEAAqQDAAKkBQACpAcAAqQJAAKkCwACpA0AAqQPAAKkEQACpCQAAqQnAAKkKgACpC0AAqQwAAKkMwACpDYAAqQ5AAKkPAACpD8AAqRBAAKkgAACpIIAAqSFAAKkhwACpIoAAqSLAAKkjAACpI0AAqSOAAKkkAACpJIAAqSTAAKklAACpJYAAqSXAAKkoAACpKEAAqSjAAKk4gACpOQAAqTmAAKk6AACpOsAAqTsAAKk7QACpO4AAqTvAAKk8QACpPMAAqT0AAKk9QACpPcAAqT4AAKlNwACpTkAAqU8AAKlPgACpUEAAqVCAAKlQwACpUQAAqVFAAKlRwACpUkAAqVKAAKlSwACpU0AAqVOAAKlVwACpVgAAqVaAAKlmQACpZsAAqWdAAKlnwACpaIAAqWjAAKlpAACpaUAAqWmAAKlqAACpaoAAqWrAAKlrAACpa4AAqWvAAKl7gACpfAAAqXzAAKl9QACpfgAAqX5AAKl+gACpfsAAqX8AAKl/gACpgAAAqYBAAKmAgACpgQAAqYFAAKmDgACpg8AAqYRAAKmUAACplIAAqZUAAKmVgACplkAAqZaAAKmWwACplwAAqZdAAKmXwACpmEAAqZiAAKmYwACpmUAAqZmAAKmpQACpqcAAqaqAAKmrAACpq8AAqawAAKmsQACprIAAqazAAKmtQACprcAAqa4AAKmuQACprsAAqa8AAKmyQACpsoAAqbLAAKmzQACpwwAAqcOAAKnEAACpxIAAqcVAAKnFgACpxcAAqcYAAKnGQACpxsAAqcdAAKnHgACpx8AAqchAAKnIgACp2EAAqdjAAKnZgACp2gAAqdrAAKnbAACp20AAqduAAKnbwACp3EAAqdzAAKndAACp3UAAqd3AAKneAACp4cAAqeUAAKnmQACp5wAAqefAAKnpAACp6cAAqeqAAKnrAACp7EAAqf8AAKoHwACqD8AAqhfAAKoYQACqGMAAqhlAAKoZwACqGoAAqhrAAKobAACqG8AAqhwAAKocgACqHMAAqh1AAKoeAACqHkAAqh6AAKofQACqH4AAqiHAAKolAACqJkAAqibAAKonQACqKIAAqilAAKoqAACqKoAAqjPAAKo8wACqRoAAqk+AAKpQQACqUMAAqlFAAKpRwACqUkAAqlLAAKpTAACqU8AAqlcAAKpbQACqW8AAqlxAAKpcwACqXUAAql3AAKpeQACqXsAAql9AAKpjgACqZEAAqmUAAKplwACqZoAAqmdAAKpoAACqaMAAqmmAAKpqAACqecAAqnpAAKp6wACqe0AAqnwAAKp8QACqfIAAqnzAAKp9AACqfYAAqn4AAKp+QACqfoAAqn8AAKp/QACqjwAAqo+AAKqQAACqkIAAqpFAAKqRgACqkcAAqpIAAKqSQACqksAAqpNAAKqTgACqk8AAqpRAAKqUgACqpEAAqqTAAKqlgACqpgAAqqbAAKqnAACqp0AAqqeAAKqnwACqqEAAqqjAAKqpAACqqUAAqqnAAKqqAACqrUAAqq2AAKqtwACqrkAAqr4AAKq+gACqvwAAqr+AAKrAQACqwIAAqsDAAKrBAACqwUAAqsHAAKrCQACqwoAAqsLAAKrDQACqw4AAqtNAAKrTwACq1EAAqtTAAKrVgACq1cAAqtYAAKrWQACq1oAAqtcAAKrXgACq18AAqtgAAKrYgACq2MAAquiAAKrpAACq6YAAquoAAKrqwACq6wAAqutAAKrrgACq68AAquxAAKrswACq7QAAqu1AAKrtwACq7gAAqv3AAKr+QACq/sAAqv9AAKsAAACrAEAAqwCAAKsAwACrAQAAqwGAAKsCAACrAkAAqwKAAKsDAACrA0AAqxMAAKsTgACrFAAAqxSAAKsVQACrFYAAqxXAAKsWAACrFkAAqxbAAKsXQACrF4AAqxfAAKsYQACrGIAAqyHAAKsqwACrNIAAqz2AAKs+QACrPsAAqz9AAKs/wACrQEAAq0DAAKtBAACrQcAAq0UAAKtIwACrSUAAq0nAAKtKQACrSsAAq0tAAKtLwACrTEAAq1AAAKtQwACrUYAAq1JAAKtTAACrU8AAq1SAAKtVQACrVcAAq2WAAKtmAACrZoAAq2cAAKtnwACraAAAq2hAAKtogACraMAAq2lAAKtpwACragAAq2pAAKtqwACrawAAq3rAAKt7QACre8AAq3xAAKt9AACrfUAAq32AAKt9wACrfgAAq36AAKt/AACrf0AAq3+AAKuAAACrgEAAq5AAAKuQgACrkQAAq5GAAKuSQACrkoAAq5LAAKuTAACrk0AAq5PAAKuUQACrlIAAq5TAAKuVQACrlYAAq6VAAKulwACrpoAAq6cAAKunwACrqAAAq6hAAKuogACrqMAAq6lAAKupwACrqgAAq6pAAKuqwACrqwAAq7rAAKu7QACru8AAq7xAAKu9AACrvUAAq72AAKu9wACrvgAAq76AAKu/AACrv0AAq7+AAKvAAACrwEAAq9AAAKvQgACr0QAAq9GAAKvSQACr0oAAq9LAAKvTAACr00AAq9PAAKvUQACr1IAAq9TAAKvVQACr1YAAq+VAAKvlwACr5kAAq+bAAKvngACr58AAq+gAAKvoQACr6IAAq+kAAKvpgACr6cAAq+oAAKvqgACr6sAAq/2AAKwGQACsDkAArBZAAKwWwACsF0AArBfAAKwYQACsGQAArBlAAKwZgACsGkAArBqAAKwbAACsG0AArBvAAKwcgACsHMAArB0AAKwdwACsHgAArCBAAKwjgACsJMAArCVAAKwlwACsJwAArCfAAKwogACsKQAArDJAAKw7QACsRQAArE4AAKxOwACsT0AArE/AAKxQQACsUMAArFFAAKxRgACsUkAArFWAAKxZwACsWkAArFrAAKxbQACsW8AArFxAAKxcwACsXUAArF3AAKxiAACsYsAArGOAAKxkQACsZQAArGXAAKxmgACsZ0AArGgAAKxogACseEAArHjAAKx5QACsecAArHqAAKx6wACsewAArHtAAKx7gACsfAAArHyAAKx8wACsfQAArH2AAKx9wACsjYAArI4AAKyOgACsjwAArI/AAKyQAACskEAArJCAAKyQwACskUAArJHAAKySAACskkAArJLAAKyTAACsosAArKNAAKykAACspIAArKVAAKylgACspcAArKYAAKymQACspsAArKdAAKyngACsp8AArKhAAKyogACsq8AArKwAAKysQACsrMAArLyAAKy9AACsvYAArL4AAKy+wACsvwAArL9AAKy/gACsv8AArMBAAKzAwACswQAArMFAAKzBwACswgAArNHAAKzSQACs0sAArNNAAKzUAACs1EAArNSAAKzUwACs1QAArNWAAKzWAACs1kAArNaAAKzXAACs10AArOcAAKzngACs6AAArOiAAKzpQACs6YAArOnAAKzqAACs6kAArOrAAKzrQACs64AArOvAAKzsQACs7IAArPxAAKz8wACs/UAArP3AAKz+gACs/sAArP8AAKz/QACs/4AArQAAAK0AgACtAMAArQEAAK0BgACtAcAArRGAAK0SAACtEoAArRMAAK0TwACtFAAArRRAAK0UgACtFMAArRVAAK0VwACtFgAArRZAAK0WwACtFwAArSBAAK0pQACtMwAArTwAAK08wACtPUAArT3AAK0+QACtPsAArT9AAK0/gACtQEAArUOAAK1HQACtR8AArUhAAK1IwACtSUAArUnAAK1KQACtSsAArU6AAK1PQACtUAAArVDAAK1RgACtUkAArVMAAK1TwACtVEAArWQAAK1kgACtZUAArWXAAK1mgACtZsAArWcAAK1nQACtZ4AArWgAAK1ogACtaMAArWkAAK1pgACtacAArXmAAK16AACteoAArXsAAK17wACtfAAArXxAAK18gACtfMAArX1AAK19wACtfgAArX5AAK1+wACtfwAArY7AAK2PQACtj8AArZBAAK2RAACtkUAArZGAAK2RwACtkgAArZKAAK2TAACtk0AArZOAAK2UAACtlEAAraQAAK2kgACtpUAAraXAAK2mgACtpsAAracAAK2nQACtp4AAragAAK2ogACtqMAArakAAK2pgACtqcAArbmAAK26AACtuoAArbsAAK27wACtvAAArbxAAK28gACtvMAArb1AAK29wACtvgAArb5AAK2+wACtvwAArc7AAK3PQACtz8AArdBAAK3RAACt0UAArdGAAK3RwACt0gAArdKAAK3TAACt00AArdOAAK3UAACt1EAAreQAAK3kgACt5QAAreWAAK3mQACt5oAArebAAK3nAACt50AArefAAK3oQACt6IAArejAAK3pQACt6YAArevAAK3sAACt7IAAre/AAK3wAACt8EAArfDAAK30AACt9EAArfSAAK31AACt+EAArfiAAK34wACt+UAArfuAAK3/QACuAoAArgZAAK4KwACuD8AArhWAAK4aAACuHEAArhyAAK4dAACuIEAAriCAAK4gwACuIUAAriGAAK4jwACuJkAArigAAAAAAAABAIAAAAAAABowQAAAAAAAAAAAAAAAAACuKg= + + Shared/CoreData/BookPlayer.xcdatamodeld/Audiobook Player 12.xcdatamodel + YnBsaXN0MDDUAAEAAgADAAQABQAGAAcAClgkdmVyc2lvblkkYXJjaGl2ZXJUJHRvcFgkb2JqZWN0 +cxIAAYagXxAPTlNLZXllZEFyY2hpdmVy0QAIAAlUcm9vdIABrxEJIAALAAwAGQA1ADYANwBTAFQAVQBWAFcAWABZAFoAWwBcAF0AXgB5AHoAewCBAIIAjgCkAKUApgCnAKgAqQCqAKsArACtAMYAyQDQANYA5QDmAPUA+AEHARYBGQB4ASkBOAE8AUABTwFVAVYBXgFtAXYBfAF9AZIBkwGbAZwBnQGpAb0BvgG/AcABwQHCAcMBxAHFAdQB4wHyAfYCBQIUAhUCJAIzAjQCQwJPAmECYgJjAmQCZQJmAmcCaAAvAncChgKHApYCpQLHAsgCzgLaAvAC/wMCAxEDIAMjAzIDQQNEA1MDYgNmA3UDhAOFA7MDtAO1A7YDtwO4A7kDugO7A7wDvQO+A78DwAPBA8IDwwPEA8UDxgPHA8gD3QPeA+YD8gQGBBUEJAQzBDcERgRVBGQEcwSCBI4EoASvBL4EzQTcBOsE+gUJBREFJgUnBS8FMAU8BVAFXwVuBX0FgQWQBZ8FrgW9BcwF2AXqBesF7AXtBe4F7wXwBfEGAAYPBh4GLQYuBj0GTAZbBmMGeAZ5BoEGjQahBrAGvwbOBtIG4QbwBv8HDgcdBykHOwdKB1kHWgdpB3gHkgeTB5kHpQe7B8oHzQfcB+sH7gf9CAwIDwgeCC0IMQhACE8IUAhiCGMIZAhlCGYIZwhoCH0IfgiGCJIIpgi1CMQI0wjXCOYI9QkECRMJIgkuCUAJTwlQCV8Jbgl9CX4JjQmcCasJwAnBCckJ1QnpCfgKBwoWChoKKQo4CkcKVgplCnEKgwqSCqEKsAq/Cs4K3QrsCwELAgsKCxYLKgs5C0gLVwtbC2oLeQuIC5cLpguyC8QL0wviC/EMAAwBDBAMHwwuDEMMRAxMDFgMbAx7DIoMmQydDKwMuwzKDNkM6Az0DQYNFQ0kDTMNQg1RDWANbw2EDYUNjQ2ZDa0NvA3LDdoN3g3tDfwOCw4aDikONQ5HDlYOZQ50DoMOkg6hDrAOxQ7GDs4O2g7uDv0PDA8bDx8PLg89D0wPWw9qD3YPiA+XD6YPtQ/ED8UP1A/jD/IQBxAIEBAQHBAwED8QThBdEGEQcBB/EI4QnRCsELgQyhDZEOgQ9xEGERURJBEzETQRNxFAEU8RXhFtEYIRgxGLEZcRqxG6EckR2BHcEesR+hIJEhgSJxIzEkUSVBJVEmQScxKCEoMSkhKhErASxRLGEs4S2hLuEv0TDBMbEx8TLhM9E0wTWxNqE3YTiBOXE6YTtRPEE9MT4hPxFAYUBxQPFBsULxQ+FE0UXBRgFG8UfhSNFJwUqxS3FMkU2BTnFPYVBRUUFSMVMhVHFUgVUBVcFXAVfxWOFZ0VoRWwFb8VzhXdFewV+BYKFhkWGhYpFjgWRxZIFlcWZhZ1FooWixaTFp8WsxbCFtEW4BbkFvMXAhcRFyAXLxc7F00XXBdrF3oXiReYF6cXthfLF8wX1BfgF/QYAxgSGCEYJRg0GEMYUhhhGHAYfBiOGJ0YrBi7GMoY2RjoGPcZDBkNGRUZIRk1GUQZUxliGWYZdRmEGZMZohmxGb0ZzxneGe0Z/BoLGgwaGxoqGjkaThpPGlcaYxp3GoYalRqkGqgatxrGGtUa5BrzGv8bERsgGy8bPhtNG2cbaBtuG3obkBufG6IbsRvAG8Mb0hvhG+Qb8xwCHAYcFRwkHDAcMRwyHDMcSBxJHFEcXRxxHIAcjxyeHKIcsRzAHM8c3hztHPkdCx0aHSkdOB1HHVYdZR10HYkdih2SHZ4dsh3BHdAd3x3jHfIeAR4QHh8eLh46HkweWx5qHnkeiB6XHqYetR7KHsse0x7fHvMfAh8RHyAfJB8zH0IfUR9gH28fex+NH5wfqx+6H8kf2B/nH/YgCyAMIBQgICA0IEMgUiBhIGUgdCCDIJIgoSCwILwgziDdIOwg+yEKISQhJSErITchTSFcIV8hbiFvIX4hgSGQIZ8hoiGxIcAhxCHTIeIiHCIdIh4iHyIgIiEiIiIjIiQiJSImIiciKCIpIioiKyIsIi0iLiIvIjAiMSIyIjMiNCI1IkoiSyJTIl8icyKCIpEioCKkIrMiwiLRIuAi4SLwIvwjDiMdIywjOyNKI1kjaCN3I4wjjSOVI6EjtSPEI9Mj4iPmI/UkBCQTJCIkMSQ9JE8kXiRtJHwkiySaJKkkuCTNJM4k1iTiJPYlBSUUJSMlJyU2JUUlVCVjJXIlfiWQJZ8lriW9Jcwl2yXqJfkmDiYPJhcmIyY3JkYmVSZkJmgmdyaGJpUmpCalJrQmwCbSJuEm8Cb/Jw4nHScsJzsnUCdRJ1knZSd5J4gnlyemJ6onuSfIJ9cn5if1KAEoEygiKDEoQChPKF4obSh8KJEokiiaKKYouij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MWNtY6lj5WQVZF1kmWTVZRFlTWW1Zbll0WYBZllmlWahZt1nGWclZ2FnnWepZ+VoIWgxaG1oqWjZaN1o4WjlaTlpPWldaY1p3WoZalVqkWqhat1rGWtVa5FrzWv9bEVsgWy9bPltNW1xba1t6W49bkFuYW6RbuFvHW9Zb5VvpW/hcB1wWXCVcNFxAXFJcYVxwXH9cjlydXKxcu1zQXNFc2VzlXPldCF0XXSZdKl05XUhdV11mXXVdgV2TXaJdsV3AXc9d3l3tXfxeEV4SXhpeJl46XkleWF5nXmteel6JXphep162XsJe1F7jXvJfAV8QXx9fLl89X0BfT19eX21fgl+DX4tfl1+rX7pfyV/YX9xf61/6YAlgGGAnYDNgRWBUYGNgcmCBYJBgn2CuYMNgxGDMYNhg7GD7YQphGWEdYSxhO2FKYVlhaGF0YYZhlWGkYbNhwmHRYeBh72HyYfdiAGIaYhtiIWItYkNiUmJVYmRic2J2YoVilGKXYqZitWK5Yshi12LdYt5i82L0YvxjCGMcYytjOmNJY01jXGNrY3pjiWOYY6RjtmPFY9Rj42PyZAxkDWQTZB9kNWREZEdkVmRlZGhkd2SGZIlkmGSnZKtkumTJZNdk2GTZZNpk72TwZPhlBGUYZSdlNmVFZUllWGVnZXZlhWWUZaBlsmXBZdBl32XuZf1mDGYbZjBmMWY5ZkVmWWZoZndmhmaKZplmqGa3ZsZm1WbhZvNnAmcRZyBnL2c+Z01nXGdxZ3JnemeGZ5pnqWe4Z8dny2faZ+ln+GgHaBZoImg0aENoUmhhaHBof2iOaJ1osmizaLtox2jbaOpo+WkIaQxpG2kqaTlpSGlXaWNpdWmEaZNpommxacBpz2neafNp9Gn8aghqHGorajpqSWpNalxqa2p6aolqmGqkarZqxWrUauNq8msBaxBrH2siazFrQGtPa1NrVGtaa2FrYmtxa4Brj2uTa61rrmu0a8Br1mvla+hr92wGbAlsGGwnbCpsOWxIbExsW2xqbHZsd2x4bHlsjmyPbJdso2y3bMZs1WzkbOhs920GbRVtJG0zbT9tUW1gbW9tfm2NbZxtq226bc9t0G3YbeRt+G4HbhZuJW4pbjhuR25WbmVudG6AbpJuoW6wbr9uzm7dbuxu+28QbxFvGW8lbzlvSG9Xb2Zvam95b4hvl2+mb7VvwW/Tb+Jv8XAAcA9wHnAtcDxwUXBScFpwZnB6cIlwmHCncKtwunDJcNhw53D2cQJxFHEjcTJxQXFQcV9xbnF9cYBxmnGbcaFxrXHDcdJx1XHkcfNx9nIFchRyF3ImcjVyOXJIcldyWHJgcmFydnJ3cn9yi3Kfcq5yvXLMctBy33Lucv1zDHMbcydzOXNIc1dzZnN1c4Rzk3Oic7dzuHPAc8xz4HPvc/50DXQRdCB0L3Q+dE10XHRodHp0iXSYdKd0tnTFdNR043TmdOp07nTydPp0/XUBdQJVJG51bGzWAA0ADgAPABAAEQASABMAFAAVABYAFwAYXxAPX3hkX3Jvb3RQYWNrYWdlViRjbGFzc11feGRfbW9kZWxOYW1lXF94ZF9jb21tZW50c18QFV9jb25maWd1cmF0aW9uc0J5TmFtZV8QF19tb2RlbFZlcnNpb25JZGVudGlmaWVygAKBCR+AAIEJHIEJHYEJHt4AGgAbABwAHQAeAB8AIAAOACEAIgAjACQAJQAmACcAKAApAAkAJwAVAC0ALgAvADAAMQAnACcAFV8QHFhEQnVja2V0Rm9yQ2xhc3Nlc3dhc0VuY29kZWRfEBpYREJ1Y2tldEZvclBhY2thZ2Vzc3RvcmFnZV8QHFhEQnVja2V0Rm9ySW50ZXJmYWNlc3N0b3JhZ2VfEA9feGRfb3duaW5nTW9kZWxfEB1YREJ1Y2tldEZvclBhY2thZ2Vzd2FzRW5jb2RlZFZfb3duZXJfEBtYREJ1Y2tldEZvckRhdGFUeXBlc3N0b3JhZ2VbX3Zpc2liaWxpdHlfEBlYREJ1Y2tldEZvckNsYXNzZXNzdG9yYWdlVV9uYW1lXxAfWERCdWNrZXRGb3JJbnRlcmZhY2Vzd2FzRW5jb2RlZF8QHlhEQnVja2V0Rm9yRGF0YVR5cGVzd2FzRW5jb2RlZF8QEF91bmlxdWVFbGVtZW50SUSABIEJGoEJGIABgASAAIEJGYEJGxAAgAWAA4AEgASAAFBTWUVT0wA4ADkADgA6AEYAUldOUy5rZXlzWk5TLm9iamVjdHOrADsAPAA9AD4APwBAAEEAQgBDAEQARYAGgAeACIAJgAqAC4AMgA2ADoAPgBCrAEcASABJAEoASwBMAE0ATgBPAFAAUYARgNmBB6OBBvyBB8uAYoEGEYEIYYECWIEI1IECx4AwVkZvbGRlcl1IYXJkY292ZXJCb29rVEJvb2tYQm9va21hcmtXQ2hhcHRlcltMaWJyYXJ5SXRlbV8QEEV4dGVybmFsUmVzb3VyY2VXQWNjb3VudFdMaWJyYXJ5XlBsYXliYWNrUmVjb3JkVVRoZW1l3xAQAF8AYABhAGIAHwBjAGQAIQBlAGYADgAjAGcAaAAmAGkAagBrACcAJwATAG8AcAAvACcAagBzADsAagB2AHcAeF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc2R1cGxpY2F0ZXNfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zc3RvcmFnZVtfaXNBYnN0cmFjdIATgDeABIAEgAKAFIEBmIAEgBOBAZqABoATgQhggBIIEwAAAAECgzB0V29yZGVyZWTTADgAOQAOAHwAfgBSoQB9gBWhAH+AFoAwXlhEX1BTdGVyZW90eXBl2QAfACMAgwAOACYAhAAhAGkAhQBHAH0AagCJABUAJwAvAHgAjV8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYARgBWAE4A2gACABAiAF9MAOAA5AA4AjwCZAFKpAJAAkQCSAJMAlACVAJYAlwCYgBiAGYAagBuAHIAdgB6AH4AgqQCaAJsAnACdAJ4AnwCgAKEAooAhgCWAJ4ApgCqALIAugDGANYAwXxATWERQTUNvbXB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AAAjfEA8ArgCvALAAHwCxALIAswAhALQADgAjALUAtgAmALcAFQJ5ABUBmQB4AHgAeAAvAHgAwAJSAHgAeAAVAHiAAIBfgACAUwgICAiAJIBWCAiAAAgQAd8QDwCuAK8AsAAfALEAsgCzACEAtAAOACMAtQC2ACYAtwAVAmoAFQGZAHgAeAB4AC8AeADAAlMAeAB4ABUAeIAAgF2AAIBTCAgICIAkgFcICIAACN8QDwCuAK8AsAAfALEAsgCzACEAtAAOACMAtQC2ACYAtwAVAEwAFQGZAHgAeAB4AC8AeADAAlQAeAB4ABUAeIAAgGKAAIBTCAgICIAkgFgICIAACN8QFABpAqYCpwKoAqkCqgKrAB8ADgKsACMCrQAmAq4CrwKwArECsgKzACECFABqAGoCtwAnAHAAagATAHMAJwBAAr8CwAAnACcAagLEAsUAcAAvXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAhWERCdWNrZXRGb3JTcGVjaWFsaXphdGlvbnNvcmRlcmVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc2R1cGxpY2F0ZXNfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zc3RvcmFnZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAkWERCdWNrZXRGb3JTcGVjaWFsaXphdGlvbnN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNvcmRlcmVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlXxAhWERCdWNrZXRGb3JTcGVjaWFsaXphdGlvbnNzdG9yYWdlXxAkWERCdWNrZXRGb3JTcGVjaWFsaXphdGlvbnNkdXBsaWNhdGVzCYATgBOAZIAEgQGYgBOAAoEBmoAEgAuBB6CAY4AEgASAE4B1gQehgQGYEwAAAAEEnkr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AtgAmALcAFQBFABUhKQB4AHgAeAAvAHgAwACYAHgAeAAVAHiAAIAQgACBAsoICAgIgCSAIAgIgAAI0wA4ADkADiHjIf8AUq8QGyHkIeUh5iHnIegh6SHqIesh7CHtIe4h7yHwA4gh8iHzA5Uh9SH2Ifch+CH5Ifoh+yH8If0h/oEC24EC3IEC3YEC3oEC34EC4IEC4YEC4oEC44EC5IEC5YEC5oEC54B3gQLogQLpgISBAuqBAuuBAuyBAu2BAu6BAu+BAvCBAvGBAvKBAvOvEBsiACIBIgIiAyIEIgUiBiIHIggiCSIKIgsiDCINIg4iDyIQIhEiEiITIhQiFSIWIhciGCIZIhqBAvSBAwyBAyOBAzqBA1KBA2mBA4CBA5eBA66BA8WBA92BA/SBBAuBBCKBBDmBBFGBBGiBBH+BBJaBBK6BBMaBBN6BBPWBBQyBBTqBBVKBBWmAMF8QEGRhcmtTZWNvbmRhcnlIZXhfEB9kYXJrVGVydGlhcnlTeXN0ZW1CYWNrZ3JvdW5kSGV4XxAcbGlnaHRRdWF0ZXJuYXJ5U3lzdGVtRmlsbEhleF8QEWxpZ2h0U2Vjb25kYXJ5SGV4XxAbZGFya1F1YXRlcm5hcnlTeXN0ZW1GaWxsSGV4XxAaZGFya1NlY29uZGFyeVN5c3RlbUZpbGxIZXhfECFsaWdodFNlY29uZGFyeVN5c3RlbUJhY2tncm91bmRIZXhfEB5kYXJrU3lzdGVtR3JvdXBlZEJhY2tncm91bmRIZXhfECBkYXJrU2Vjb25kYXJ5U3lzdGVtQmFja2dyb3VuZEhleF8QGGxpZ2h0U3lzdGVtQmFja2dyb3VuZEhleF8QEGRhcmtTZXBhcmF0b3JIZXhfEBFkYXJrU3lzdGVtRmlsbEhleF8QGmxpZ2h0VGVydGlhcnlTeXN0ZW1GaWxsSGV4XmxpZ2h0QWNjZW50SGV4XxARbGlnaHRTZXBhcmF0b3JIZXhfEBJsaWdodFN5c3RlbUZpbGxIZXhfEA9saWdodFByaW1hcnlIZXheZGFya1ByaW1hcnlIZXhfEBdkYXJrU3lzdGVtQmFja2dyb3VuZEhleF8QGWRhcmtUZXJ0aWFyeVN5c3RlbUZpbGxIZXhfEBtsaWdodFNlY29uZGFyeVN5c3RlbUZpbGxIZXhYc2VsZWN0ZWRdZGFya0FjY2V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vACcAagBzAD4AallqWWsAeF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc2R1cGxpY2F0ZXNfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zc3RvcmFnZYATgQcOgASABIACgQb+gQGYgASAE4EBmoAJgBOBB26BBv0IEu4QJ53TADgAOQAOWW9ZcQBSoQB9gBWhWXKBBv+AMNkAHwAjWXUADgAmWXYAIQBpWXcASgB9AGoAiQAVACcALwB4WX9fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WBBvyAFYATgDaAAIAECIEHANMAOAA5AA5ZgVmLAFKpAJAAkQCSAJMAlACVAJYAlwCYgBiAGYAagBuAHIAdgB6AH4AgqVmMWY1ZjlmPWZBZkVmSWZNZlIEHAYEHA4EHBIEHBoEHB4EHCYEHCoEHDIEHDYAw3xAPAK4ArwCwAB8AsQCyALMAIQC0AA4AIwC1ALYAJgC3ABVZmAAVWXIAeAB4AHgALwB4AMAAkAB4AHgAFQB4gACBBwKAAIEG/wgICAiAJIAYCAiAAAjSADkADlmmAMiggCPfEA8ArgCvALAAHwCxALIAswAhALQADgAjALUAtgAmALcAFQAVABVZcgB4AHgAeAAvAHgAwACRAHgAeAAVAHiAAIAAgACBBv8ICAgIgCSAGQgIgAAI3xAPAK4ArwCwAB8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AACeHAAAniAAAJ4oAACeMAAAnjQAAJ44AACeQAAAnkQAAJ9AAACfSAAAn1AAAJ9YAACfYAAAn2QAAJ9oAACfbAAAn3AAAJ94AACfgAAAn4QAAJ+IAACfkAAAn5QAAKDgAAChcAAAogAAAKKAAACjHAAAo7gAAKQ4AACk1AAApWQAAKXwAACmjAAApxwAAKesAACoPAAAqNgAAKjcAACo5AAAqOwAAKj0AACo/AAAqQgAAKkQAACpGAAAqSQAAKksAACpNAAAqUAAAKlIAACpUAAAqVgAAKlgAACpaAAAqXQAAKmAAACppAAAqdgAAKnkAACp7AAAqfgAAKoAAACqCAAAqpwAAKssAACryAAArFgAAKxgAACsaAAArHAAAKx4AACsgAAArIgAAKyMAACslAAArMgAAK0UAACtHAAArSQAAK0sAACtNAAArTwAAK1EAACtTAAArVQAAK1cAACtqAAArbAAAK24AACtwAAArcgAAK3QAACt2AAAreAAAK3oAACt8AAArfgAAK70AACu/AAArwQAAK8MAACvFAAArxgAAK8cAACvIAAAryQAAK8sAACvNAAArzgAAK88AACvRAAAr0gAAK9sAACvcAAAr3gAALB0AACwfAAAsIQAALCMAACwlAAAsJgAALCcAACwoAAAsKQAALCsAACwtAAAsLgAALC8AACwxAAAsMgAALHEAACxzAAAsdQAALHcAACx5AAAsegAALHsAACx8AAAsfQAALH8AACyBAAAsggAALIMAACyFAAAshgAALI8AACyQAAAskgAALNEAACzTAAAs1QAALNcAACzZAAAs2gAALNsAACzcAAAs3QAALN8AACzhAAAs4gAALOMAACzlAAAs5gAALSUAAC0nAAAtKQAALSsAAC0tAAAtLgAALS8AAC0wAAAtMQAALTMAAC01AAAtNgAALTcAAC05AAAtOgAALUMAAC1EAAAtRgAALYUAAC2HAAAtiQAALYsAAC2NAAAtjgAALY8AAC2QAAAtkQAALZMAAC2VAAAtlgAALZcAAC2ZAAAtmgAALdkAAC3bAAAt3QAALd8AAC3hAAAt4gAALeMAAC3kAAAt5QAALecAAC3pAAAt6gAALesAAC3tAAAt7gAALfsAAC38AAAt/QAALf8AAC4+AAAuQAAALkIAAC5EAAAuRgAALkcAAC5IAAAuSQAALkoAAC5MAAAuTgAALk8AAC5QAAAuUgAALlMAAC6SAAAulAAALpYAAC6YAAAumgAALpsAAC6cAAAunQAALp4AAC6gAAAuogAALqMAAC6kAAAupgAALqcAAC6zAAAuwAAALu0AAC7vAAAu8QAALvMAAC71AAAu9wAALvkAAC77AAAu/QAALv8AAC8BAAAvAwAALwUAAC8HAAAvCQAALwsAAC8NAAAvDwAALxEAAC8TAAAvFQAALxcAAC9EAAAvRgAAL0gAAC9KAAAvTQAAL1AAAC9TAAAvVgAAL1kAAC9cAAAvXwAAL2IAAC9lAAAvaAAAL2sAAC9uAAAvcQAAL3QAAC93AAAvegAAL30AAC+AAAAvggAAL4kAAC+PAAAvnQAAL6YAAC+yAAAvxQAAL8sAAC/VAAAv2gAAL+cAAC/yAAAv/QAAMAIAADAKAAAwEgAAMBwAADAnAAAwOwAAMEUAADBYAAAwZQAAMLAAADDTAAAw8wAAMRMAADEVAAAxFwAAMRkAADEbAAAxHQAAMR4AADEfAAAxIQAAMSIAADEkAAAxJQAAMScAADEpAAAxKgAAMSsAADEtAAAxLgAAMTMAADFAAAAxRQAAMUcAADFJAAAxTgAAMVAAADFSAAAxVAAAMXkAADGdAAAxxAAAMegAADHqAAAx7AAAMe4AADHwAAAx8gAAMfQAADH1AAAx9wAAMgQAADIVAAAyFwAAMhkAADIbAAAyHQAAMh8AADIhAAAyIwAAMiUAADI2AAAyOAAAMjoAADI8AAAyPgAAMkAAADJCAAAyRAAAMkYAADJIAAAyhwAAMokAADKLAAAyjQAAMo8AADKQAAAykQAAMpIAADKTAAAylQAAMpcAADKYAAAymQAAMpsAADKcAAAy2wAAMt0AADLfAAAy4QAAMuMAADLkAAAy5QAAMuYAADLnAAAy6QAAMusAADLsAAAy7QAAMu8AADLwAAAzLwAAMzEAADMzAAAzNQAAMzcAADM4AAAzOQAAMzoAADM7AAAzPQAAMz8AADNAAAAzQQAAM0MAADNEAAAzUQAAM1IAADNTAAAzVQAAM5QAADOWAAAzmAAAM5oAADOcAAAznQAAM54AADOfAAAzoAAAM6IAADOkAAAzpQAAM6YAADOoAAAzqQAAM+gAADPqAAAz7AAAM+4AADPwAAAz8QAAM/IAADPzAAAz9AAAM/YAADP4AAAz+QAAM/oAADP8AAAz/QAANDwAADQ+AAA0QAAANEIAADREAAA0RQAANEYAADRHAAA0SAAANEoAADRMAAA0TQAANE4AADRQAAA0UQAANJAAADSSAAA0lAAANJYAADSYAAA0mQAANJoAADSbAAA0nAAANJ4AADSgAAA0oQAANKIAADSkAAA0pQAANOQAADTmAAA06AAANOoAADTsAAA07QAANO4AADTvAAA08AAANPIAADT0AAA09QAANPYAADT4AAA0+QAANR4AADVCAAA1aQAANY0AADWPAAA1kQAANZMAADWVAAA1lwAANZkAADWaAAA1nAAANakAADW4AAA1ugAANbwAADW+AAA1wAAANcIAADXEAAA1xgAANdUAADXXAAA12QAANdsAADXdAAA13wAANeEAADXjAAA15QAANiQAADYmAAA2KAAANioAADYsAAA2LQAANi4AADYvAAA2MAAANjIAADY0AAA2NQAANjYAADY4AAA2OQAANngAADZ6AAA2fAAANn4AADaAAAA2gQAANoIAADaDAAA2hAAANoYAADaIAAA2iQAANooAADaMAAA2jQAANswAADbOAAA20AAANtIAADbUAAA21QAANtYAADbXAAA22AAANtoAADbcAAA23QAANt4AADbgAAA24QAANyAAADciAAA3JAAANyYAADcoAAA3KQAANyoAADcrAAA3LAAANy4AADcwAAA3MQAANzIAADc0AAA3NQAAN3QAADd2AAA3eAAAN3oAADd8AAA3fQAAN34AADd/AAA3gAAAN4IAADeEAAA3hQAAN4YAADeIAAA3iQAAN8gAADfKAAA3zAAAN84AADfQAAA30QAAN9IAADfTAAA31AAAN9YAADfYAAA32QAAN9oAADfcAAA33QAAOBwAADgeAAA4IAAAOCIAADgkAAA4JQAAOCYAADgnAAA4KAAAOCoAADgsAAA4LQAAOC4AADgwAAA4MQAAODoAADhNAAA4WgAAOG0AADh6AAA4jQAAOKQAADi2AAA5AQAAOSQAADlEAAA5ZAAAOWYAADloAAA5agAAOWwAADluAAA5bwAAOXAAADlyAAA5cwAAOXUAADl2AAA5eAAAOXoAADl7AAA5fAAAOX4AADl/AAA5hAAAOZEAADmWAAA5mAAAOZoAADmfAAA5oQAAOaMAADmlAAA5ugAAOd8AADoDAAA6KgAAOk4AADpQAAA6UgAAOlQAADpWAAA6WAAAOloAADpbAAA6XQAAOmoAADp7AAA6fQAAOn8AADqBAAA6gwAAOoUAADqHAAA6iQAAOosAADqcAAA6ngAAOqAAADqiAAA6pAAAOqYAADqoAAA6qgAAOqwAADquAAA67QAAOu8AADrxAAA68wAAOvUAADr2AAA69wAAOvgAADr5AAA6+wAAOv0AADr+AAA6/wAAOwEAADsCAAA7QQAAO0MAADtFAAA7RwAAO0kAADtKAAA7SwAAO0wAADtNAAA7TwAAO1EAADtSAAA7UwAAO1UAADtWAAA7lQAAO5cAADuZAAA7mwAAO50AADueAAA7nwAAO6AAADuhAAA7owAAO6UAADumAAA7pwAAO6kAADuqAAA7twAAO7gAADu5AAA7uwAAO/oAADv8AAA7/gAAPAAAADwCAAA8AwAAPAQAADwFAAA8BgAAPAgAADwKAAA8CwAAPAwAADwOAAA8DwAAPE4AADxQAAA8UgAAPFQAADxWAAA8VwAAPFgAADxZAAA8WgAAPFwAADxeAAA8XwAAPGAAADxiAAA8YwAAPKIAADykAAA8pgAAPKgAADyqAAA8qwAAPKwAADytAAA8rgAAPLAAADyyAAA8swAAPLQAADy2AAA8twAAPPYAADz4AAA8+gAAPPwAADz+AAA8/wAAPQAAAD0BAAA9AgAAPQQAAD0GAAA9BwAAPQgAAD0KAAA9CwAAPUoAAD1MAAA9TgAAPVAAAD1SAAA9UwAAPVQAAD1VAAA9VgAAPVgAAD1aAAA9WwAAPVwAAD1eAAA9XwAAPYQAAD2oAAA9zwAAPfMAAD31AAA99wAAPfkAAD37AAA9/QAAPf8AAD4AAAA+AgAAPg8AAD4eAAA+IAAAPiIAAD4kAAA+JgAAPigAAD4qAAA+LAAAPjsAAD49AAA+PwAAPkEAAD5DAAA+RQAAPkcAAD5JAAA+SwAAPmsAAD6WAAA+sAAAPskAAD7jAAA/AwAAPyYAAD9lAAA/ZwAAP2kAAD9rAAA/bQAAP24AAD9vAAA/cAAAP3EAAD9zAAA/dQAAP3YAAD93AAA/eQAAP3oAAD+5AAA/uwAAP70AAD+/AAA/wQAAP8IAAD/DAAA/xAAAP8UAAD/HAAA/yQAAP8oAAD/LAAA/zQAAP84AAEANAABADwAAQBEAAEATAABAFQAAQBYAAEAXAABAGAAAQBkAAEAbAABAHQAAQB4AAEAfAABAIQAAQCIAAEBhAABAYwAAQGUAAEBnAABAaQAAQGoAAEBrAABAbAAAQG0AAEBvAABAcQAAQHIAAEBzAABAdQAAQHYAAEB5AABAuAAAQLoAAEC8AABAvgAAQMAAAEDBAABAwgAAQMMAAEDEAABAxgAAQMgAAEDJAABAygAAQMwAAEDNAABBDAAAQQ4AAEEQAABBEgAAQRQAAEEVAABBFgAAQRcAAEEYAABBGgAAQRwAAEEdAABBHgAAQSAAAEEhAABBYAAAQWIAAEFkAABBZgAAQWgAAEFpAABBagAAQWsAAEFsAABBbgAAQXAAAEFxAABBcgAAQXQAAEF1AABBfgAAQYwAAEGZAABBpwAAQbQAAEHHAABB3gAAQfAAAEI7AABCXgAAQn4AAEKeAABCoAAAQqIAAEKkAABCpgAAQqgAAEKpAABCqgAAQqwAAEKtAABCrwAAQrAAAEKyAABCtAAAQrUAAEK2AABCuAAAQrkAAEK+AABCywAAQtAAAELSAABC1AAAQtkAAELbAABC3QAAQt8AAEMEAABDKAAAQ08AAENzAABDdQAAQ3cAAEN5AABDewAAQ30AAEN/AABDgAAAQ4IAAEOPAABDoAAAQ6IAAEOkAABDpgAAQ6gAAEOqAABDrAAAQ64AAEOwAABDwQAAQ8MAAEPFAABDxwAAQ8kAAEPLAABDzQAAQ88AAEPRAABD0wAARBIAAEQUAABEFgAARBgAAEQaAABEGwAARBwAAEQdAABEHgAARCAAAEQiAABEIwAARCQAAEQmAABEJwAARGYAAERoAABEagAARGwAAERuAABEbwAARHAAAERxAABEcgAARHQAAER2AABEdwAARHgAAER6AABEewAARLoAAES8AABEvgAARMAAAETCAABEwwAARMQAAETFAABExgAARMgAAETKAABEywAARMwAAETOAABEzwAARNwAAETdAABE3gAAROAAAEUfAABFIQAARSMAAEUlAABFJwAARSgAAEUpAABFKgAARSsAAEUtAABFLwAARTAAAEUxAABFMwAARTQAAEVzAABFdQAARXcAAEV5AABFewAARXwAAEV9AABFfgAARX8AAEWBAABFgwAARYQAAEWFAABFhwAARYgAAEXHAABFyQAARcsAAEXNAABFzwAARdAAAEXRAABF0gAARdMAAEXVAABF1wAARdgAAEXZAABF2wAARdwAAEYbAABGHQAARh8AAEYhAABGIwAARiQAAEYlAABGJgAARicAAEYpAABGKwAARiwAAEYtAABGLwAARjAAAEZvAABGcQAARnMAAEZ1AABGdwAARngAAEZ5AABGegAARnsAAEZ9AABGfwAARoAAAEaBAABGgwAARoQAAEapAABGzQAARvQAAEcYAABHGgAARxwAAEceAABHIAAARyIAAEckAABHJQAARycAAEc0AABHQwAAR0UAAEdHAABHSQAAR0sAAEdNAABHTwAAR1EAAEdgAABHYgAAR2QAAEdmAABHaAAAR2sAAEduAABHcQAAR3MAAEeyAABHtAAAR7YAAEe4AABHugAAR7sAAEe8AABHvQAAR74AAEfAAABHwgAAR8MAAEfEAABHxgAAR8cAAEgGAABICAAASAoAAEgMAABIDgAASA8AAEgQAABIEQAASBIAAEgUAABIFgAASBcAAEgYAABIGgAASBsAAEgdAABIXAAASF4AAEhgAABIYgAASGQAAEhlAABIZgAASGcAAEhoAABIagAASGwAAEhtAABIbgAASHAAAEhxAABIsAAASLIAAEi0AABItgAASLgAAEi5AABIugAASLsAAEi8AABIvgAASMAAAEjBAABIwgAASMQAAEjFAABJCAAASSwAAElQAABJcwAASZoAAEm6AABJ4QAASggAAEooAABKTAAASnAAAEpyAABKdAAASnYAAEp4AABKegAASnwAAEp/AABKgQAASoMAAEqGAABKiAAASooAAEqNAABKjwAASpAAAEqVAABKogAASqUAAEqnAABKqgAASqwAAEquAABK0wAASvcAAEseAABLQgAAS0QAAEtGAABLSAAAS0oAAEtMAABLTgAAS08AAEtRAABLXgAAS3EAAEtzAABLdQAAS3cAAEt5AABLewAAS30AAEt/AABLgQAAS4MAAEuWAABLmAAAS5oAAEucAABLngAAS6AAAEuiAABLpAAAS6YAAEuoAABLqgAAS+kAAEvrAABL7QAAS+8AAEvxAABL8gAAS/MAAEv0AABL9QAAS/cAAEv5AABL+gAAS/sAAEv9AABL/gAATAcAAEwIAABMCgAATEkAAExLAABMTQAATE8AAExRAABMUgAATFMAAExUAABMVQAATFcAAExZAABMWgAATFsAAExdAABMXgAATJ0AAEyfAABMoQAATKMAAEylAABMpgAATKcAAEyoAABMqQAATKsAAEytAABMrgAATK8AAEyxAABMsgAATLsAAEy8AABMvgAATP0AAEz/AABNAQAATQMAAE0FAABNBgAATQcAAE0IAABNCQAATQsAAE0NAABNDgAATQ8AAE0RAABNEgAATVEAAE1TAABNVQAATVcAAE1ZAABNWgAATVsAAE1cAABNXQAATV8AAE1hAABNYgAATWMAAE1lAABNZgAATW8AAE1wAABNcgAATbEAAE2zAABNtQAATbcAAE25AABNugAATbsAAE28AABNvQAATb8AAE3BAABNwgAATcMAAE3FAABNxgAATgUAAE4HAABOCQAATgsAAE4NAABODgAATg8AAE4QAABOEQAAThMAAE4VAABOFgAAThcAAE4ZAABOGgAATicAAE4oAABOKQAATisAAE5qAABObAAATm4AAE5wAABOcgAATnMAAE50AABOdQAATnYAAE54AABOegAATnsAAE58AABOfgAATn8AAE6+AABOwAAATsIAAE7EAABOxgAATscAAE7IAABOyQAATsoAAE7MAABOzgAATs8AAE7QAABO0gAATtMAAE7hAABO7gAATv0AAE7/AABPAQAATwMAAE8FAABPBwAATwkAAE8LAABPGgAATxwAAE8fAABPIgAATyUAAE8oAABPKwAATy4AAE8wAABPNwAATz4AAE9BAABPTAAAT1cAAE9jAABPrgAAT9EAAE/xAABQEQAAUBMAAFAVAABQFwAAUBkAAFAbAABQHAAAUB0AAFAfAABQIAAAUCIAAFAjAABQJQAAUCcAAFAoAABQKQAAUCsAAFAsAABQNQAAUEIAAFBHAABQSQAAUEsAAFBQAABQUgAAUFUAAFBXAABQfAAAUKAAAFDHAABQ6wAAUO0AAFDvAABQ8QAAUPMAAFD1AABQ9wAAUPgAAFD6AABRBwAAURgAAFEaAABRHAAAUR4AAFEgAABRIgAAUSQAAFEmAABRKAAAUTkAAFE7AABRPQAAUT8AAFFBAABRQwAAUUUAAFFHAABRSgAAUUwAAFGLAABRjQAAUY8AAFGRAABRkwAAUZQAAFGVAABRlgAAUZcAAFGZAABRmwAAUZwAAFGdAABRnwAAUaAAAFHfAABR4QAAUeMAAFHlAABR5wAAUegAAFHpAABR6gAAUesAAFHtAABR7wAAUfAAAFHxAABR8wAAUfQAAFIzAABSNQAAUjcAAFI5AABSOwAAUjwAAFI9AABSPgAAUj8AAFJBAABSQwAAUkQAAFJFAABSRwAAUkgAAFJVAABSVgAAUlcAAFJZAABSmAAAUpoAAFKcAABSngAAUqAAAFKhAABSogAAUqMAAFKkAABSpgAAUqgAAFKpAABSqgAAUqwAAFKtAABS7AAAUu4AAFLwAABS8gAAUvQAAFL1AABS9gAAUvcAAFL4AABS+gAAUvwAAFL9AABS/gAAUwAAAFMBAABTQAAAU0IAAFNEAABTRgAAU0gAAFNJAABTSgAAU0sAAFNMAABTTgAAU1AAAFNRAABTUgAAU1QAAFNVAABTlAAAU5YAAFOYAABTmgAAU5wAAFOdAABTngAAU58AAFOgAABTogAAU6QAAFOlAABTpgAAU6gAAFOpAABT6AAAU+oAAFPsAABT7gAAU/AAAFPxAABT8gAAU/MAAFP0AABT9gAAU/gAAFP5AABT+gAAU/wAAFP9AABUIgAAVEYAAFRtAABUkQAAVJMAAFSVAABUlwAAVJkAAFSbAABUnQAAVJ4AAFShAABUrgAAVL0AAFS/AABUwQAAVMMAAFTFAABUxwAAVMkAAFTLAABU2gAAVN0AAFTgAABU4wAAVOYAAFTpAABU7AAAVO8AAFTxAABVMAAAVTIAAFU1AABVNwAAVToAAFU7AABVPAAAVT0AAFU+AABVQAAAVUIAAFVDAABVRAAAVUYAAFVHAABVSQAAVYgAAFWKAABVjAAAVY4AAFWRAABVkgAAVZMAAFWUAABVlQAAVZcAAFWZAABVmgAAVZsAAFWdAABVngAAVd0AAFXfAABV4QAAVeMAAFXmAABV5wAAVegAAFXpAABV6gAAVewAAFXuAABV7wAAVfAAAFXyAABV8wAAVjIAAFY0AABWNwAAVjkAAFY8AABWPQAAVj4AAFY/AABWQAAAVkIAAFZEAABWRQAAVkYAAFZIAABWSQAAVksAAFaKAABWjAAAVo4AAFaQAABWkwAAVpQAAFaVAABWlgAAVpcAAFaZAABWmwAAVpwAAFadAABWnwAAVqAAAFbfAABW4QAAVuMAAFblAABW6AAAVukAAFbqAABW6wAAVuwAAFbuAABW8AAAVvEAAFbyAABW9AAAVvUAAFc0AABXNgAAVzgAAFc6AABXPQAAVz4AAFc/AABXQAAAV0EAAFdDAABXRQAAV0YAAFdHAABXSQAAV0oAAFeVAABXuAAAV9gAAFf4AABX+gAAV/wAAFf+AABYAAAAWAIAAFgDAABYBAAAWAcAAFgIAABYCgAAWAsAAFgNAABYDwAAWBAAAFgRAABYFAAAWBUAAFgaAABYJwAAWCwAAFguAABYMAAAWDUAAFg4AABYOwAAWD0AAFhiAABYhgAAWK0AAFjRAABY1AAAWNYAAFjYAABY2gAAWNwAAFjeAABY3wAAWOIAAFjvAABZAAAAWQIAAFkEAABZBgAAWQgAAFkKAABZDAAAWQ4AAFkQAABZIQAAWSQAAFknAABZKgAAWS0AAFkwAABZMwAAWTYAAFk5AABZOwAAWXoAAFl8AABZfgAAWYAAAFmDAABZhAAAWYUAAFmGAABZhwAAWYkAAFmLAABZjAAAWY0AAFmPAABZkAAAWc8AAFnRAABZ0wAAWdUAAFnYAABZ2QAAWdoAAFnbAABZ3AAAWd4AAFngAABZ4QAAWeIAAFnkAABZ5QAAWiQAAFomAABaKQAAWisAAFouAABaLwAAWjAAAFoxAABaMgAAWjQAAFo2AABaNwAAWjgAAFo6AABaOwAAWkgAAFpJAABaSgAAWkwAAFqLAABajQAAWo8AAFqRAABalAAAWpUAAFqWAABalwAAWpgAAFqaAABanAAAWp0AAFqeAABaoAAAWqEAAFrgAABa4gAAWuQAAFrmAABa6QAAWuoAAFrrAABa7AAAWu0AAFrvAABa8QAAWvIAAFrzAABa9QAAWvYAAFs1AABbNwAAWzkAAFs7AABbPgAAWz8AAFtAAABbQQAAW0IAAFtEAABbRgAAW0cAAFtIAABbSgAAW0sAAFuKAABbjAAAW44AAFuQAABbkwAAW5QAAFuVAABblgAAW5cAAFuZAABbmwAAW5wAAFudAABbnwAAW6AAAFvfAABb4QAAW+MAAFvlAABb6AAAW+kAAFvqAABb6wAAW+wAAFvuAABb8AAAW/EAAFvyAABb9AAAW/UAAFwaAABcPgAAXGUAAFyJAABcjAAAXI4AAFyQAABckgAAXJQAAFyWAABclwAAXJoAAFynAABctgAAXLgAAFy6AABcvAAAXL4AAFzAAABcwgAAXMQAAFzTAABc1gAAXNkAAFzcAABc3wAAXOIAAFzlAABc6AAAXOoAAF0pAABdKwAAXS0AAF0vAABdMgAAXTMAAF00AABdNQAAXTYAAF04AABdOgAAXTsAAF08AABdPgAAXT8AAF1+AABdgAAAXYIAAF2EAABdhwAAXYgAAF2JAABdigAAXYsAAF2NAABdjwAAXZAAAF2RAABdkwAAXZQAAF3TAABd1QAAXdcAAF3ZAABd3AAAXd0AAF3eAABd3wAAXeAAAF3iAABd5AAAXeUAAF3mAABd6AAAXekAAF4oAABeKgAAXiwAAF4uAABeMQAAXjIAAF4zAABeNAAAXjUAAF43AABeOQAAXjoAAF47AABePQAAXj4AAF59AABefwAAXoEAAF6DAABehgAAXocAAF6IAABeiQAAXooAAF6MAABejgAAXo8AAF6QAABekgAAXpMAAF7SAABe1AAAXtYAAF7YAABe2wAAXtwAAF7dAABe3gAAXt8AAF7hAABe4wAAXuQAAF7lAABe5wAAXugAAF8nAABfKQAAXysAAF8tAABfMAAAXzEAAF8yAABfMwAAXzQAAF82AABfOAAAXzkAAF86AABfPAAAXz0AAF+IAABfqwAAX8sAAF/rAABf7QAAX+8AAF/xAABf8wAAX/UAAF/2AABf9wAAX/oAAF/7AABf/QAAX/4AAGAAAABgAgAAYAMAAGAEAABgBwAAYAgAAGANAABgGgAAYB8AAGAhAABgIwAAYCgAAGArAABgLgAAYDAAAGBVAABgeQAAYKAAAGDEAABgxwAAYMkAAGDLAABgzQAAYM8AAGDRAABg0gAAYNUAAGDiAABg8wAAYPUAAGD3AABg+QAAYPsAAGD9AABg/wAAYQEAAGEDAABhFAAAYRcAAGEaAABhHQAAYSAAAGEjAABhJgAAYSkAAGEsAABhLgAAYW0AAGFvAABhcQAAYXMAAGF2AABhdwAAYXgAAGF5AABhegAAYXwAAGF+AABhfwAAYYAAAGGCAABhgwAAYcIAAGHEAABhxgAAYcgAAGHLAABhzAAAYc0AAGHOAABhzwAAYdEAAGHTAABh1AAAYdUAAGHXAABh2AAAYhcAAGIZAABiHAAAYh4AAGIhAABiIgAAYiMAAGIkAABiJQAAYicAAGIpAABiKgAAYisAAGItAABiLgAAYjsAAGI8AABiPQAAYj8AAGJ+AABigAAAYoIAAGKEAABihwAAYogAAGKJAABiigAAYosAAGKNAABijwAAYpAAAGKRAABikwAAYpQAAGLTAABi1QAAYtcAAGLZAABi3AAAYt0AAGLeAABi3wAAYuAAAGLiAABi5AAAYuUAAGLmAABi6AAAYukAAGMoAABjKgAAYywAAGMuAABjMQAAYzIAAGMzAABjNAAAYzUAAGM3AABjOQAAYzoAAGM7AABjPQAAYz4AAGN9AABjfwAAY4EAAGODAABjhgAAY4cAAGOIAABjiQAAY4oAAGOMAABjjgAAY48AAGOQAABjkgAAY5MAAGPSAABj1AAAY9YAAGPYAABj2wAAY9wAAGPdAABj3gAAY98AAGPhAABj4wAAY+QAAGPlAABj5wAAY+gAAGQNAABkMQAAZFgAAGR8AABkfwAAZIEAAGSDAABkhQAAZIcAAGSJAABkigAAZI0AAGSaAABkqQAAZKsAAGStAABkrwAAZLEAAGSzAABktQAAZLcAAGTGAABkyQAAZMwAAGTPAABk0gAAZNUAAGTYAABk2wAAZN0AAGUcAABlHgAAZSEAAGUjAABlJgAAZScAAGUoAABlKQAAZSoAAGUsAABlLgAAZS8AAGUwAABlMgAAZTMAAGVyAABldAAAZXYAAGV4AABlewAAZXwAAGV9AABlfgAAZX8AAGWBAABlgwAAZYQAAGWFAABlhwAAZYgAAGXHAABlyQAAZcsAAGXNAABl0AAAZdEAAGXSAABl0wAAZdQAAGXWAABl2AAAZdkAAGXaAABl3AAAZd0AAGYcAABmHgAAZiEAAGYjAABmJgAAZicAAGYoAABmKQAAZioAAGYsAABmLgAAZi8AAGYwAABmMgAAZjMAAGY1AABmdAAAZnYAAGZ4AABmegAAZn0AAGZ+AABmfwAAZoAAAGaBAABmgwAAZoUAAGaGAABmhwAAZokAAGaKAABmyQAAZssAAGbNAABmzwAAZtIAAGbTAABm1AAAZtUAAGbWAABm2AAAZtoAAGbbAABm3AAAZt4AAGbfAABnHgAAZyAAAGciAABnJAAAZycAAGcoAABnKQAAZyoAAGcrAABnLQAAZy8AAGcwAABnMQAAZzMAAGc0AABnfwAAZ6IAAGfCAABn4gAAZ+QAAGfmAABn6AAAZ+oAAGfsAABn7QAAZ+4AAGfxAABn8gAAZ/QAAGf1AABn9wAAZ/kAAGf6AABn+wAAZ/4AAGf/AABoBAAAaBEAAGgWAABoGAAAaBoAAGgfAABoIgAAaCUAAGgnAABoTAAAaHAAAGiXAABouwAAaL4AAGjAAABowgAAaMQAAGjGAABoyAAAaMkAAGjMAABo2QAAaOoAAGjsAABo7gAAaPAAAGjyAABo9AAAaPYAAGj4AABo+gAAaQsAAGkOAABpEQAAaRQAAGkXAABpGgAAaR0AAGkgAABpIwAAaSUAAGlkAABpZgAAaWgAAGlqAABpbQAAaW4AAGlvAABpcAAAaXEAAGlzAABpdQAAaXYAAGl3AABpeQAAaXoAAGm5AABpuwAAab0AAGm/AABpwgAAacMAAGnEAABpxQAAacYAAGnIAABpygAAacsAAGnMAABpzgAAac8AAGoOAABqEAAAahMAAGoVAABqGAAAahkAAGoaAABqGwAAahwAAGoeAABqIAAAaiEAAGoiAABqJAAAaiUAAGoyAABqMwAAajQAAGo2AABqdQAAancAAGp5AABqewAAan4AAGp/AABqgAAAaoEAAGqCAABqhAAAaoYAAGqHAABqiAAAaooAAGqLAABqygAAaswAAGrOAABq0AAAatMAAGrUAABq1QAAatYAAGrXAABq2QAAatsAAGrcAABq3QAAat8AAGrgAABrHwAAayEAAGsjAABrJQAAaygAAGspAABrKgAAaysAAGssAABrLgAAazAAAGsxAABrMgAAazQAAGs1AABrdAAAa3YAAGt4AABregAAa30AAGt+AABrfwAAa4AAAGuBAABrgwAAa4UAAGuGAABrhwAAa4kAAGuKAABryQAAa8sAAGvNAABrzwAAa9IAAGvTAABr1AAAa9UAAGvWAABr2AAAa9oAAGvbAABr3AAAa94AAGvfAABsBAAAbCgAAGxPAABscwAAbHYAAGx4AABsegAAbHwAAGx+AABsgAAAbIEAAGyEAABskQAAbKAAAGyiAABspAAAbKYAAGyoAABsqgAAbKwAAGyuAABsvQAAbMAAAGzDAABsxgAAbMkAAGzMAABszwAAbNIAAGzUAABtEwAAbRUAAG0XAABtGQAAbRwAAG0dAABtHgAAbR8AAG0gAABtIgAAbSQAAG0lAABtJgAAbSgAAG0pAABtaAAAbWoAAG1sAABtbgAAbXE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AAH4IAAB+CgAAfgwAAH4NAAB+DgAAfhAAAH4RAAB+FAAAflMAAH5VAAB+VwAAflkAAH5cAAB+XQAAfl4AAH5fAAB+YAAAfmIAAH5kAAB+ZQAAfmYAAH5oAAB+aQAAfqgAAH6qAAB+rAAAfq4AAH6xAAB+sgAAfrMAAH60AAB+tQAAfrcAAH65AAB+ugAAfrsAAH69AAB+vgAAfv0AAH7/AAB/AQAAfwMAAH8GAAB/BwAAfwgAAH8JAAB/CgAAfwwAAH8OAAB/DwAAfxAAAH8SAAB/EwAAf14AAH+BAAB/oQAAf8EAAH/DAAB/xQAAf8cAAH/JAAB/ywAAf8wAAH/NAAB/0AAAf9EAAH/TAAB/1AAAf9YAAH/YAAB/2QAAf9oAAH/dAAB/3gAAf+MAAH/wAAB/9QAAf/cAAH/5AAB//gAAgAEAAIAEAACABgAAgCsAAIBPAACAdgAAgJoAAICdAACAnwAAgKEAAICjAACApQAAgKcAAICoAACAqwAAgLgAAIDJAACAywAAgM0AAIDPAACA0QAAgNMAAIDVAACA1wAAgNkAAIDqAACA7QAAgPAAAIDzAACA9gAAgPkAAID8AACA/wAAgQIAAIEEAACBQwAAgUUAAIFHAACBSQAAgUwAAIFNAACBTgAAgU8AAIFQAACBUgAAgVQAAIFVAACBVgAAgVgAAIFZAACBmAAAgZoAAIGcAACBngAAgaEAAIGiAACBowAAgaQAAIGlAACBpwAAgakAAIGqAACBqwAAga0AAIGuAACB7QAAge8AAIHyAACB9AAAgfcAAIH4AACB+QAAgfoAAIH7AACB/QAAgf8AAIIAAACCAQAAggMAAIIEAACCEQAAghIAAIITAACCFQAAglQAAIJWAACCWAAAgloAAIJdAACCXgAAgl8AAIJgAACCYQAAgmMAAIJlAACCZgAAgmcAAIJpAACCagAAgqkAAIKrAACCrQAAgq8AAIKyAACCswAAgrQAAIK1AACCtgAAgrgAAIK6AACCuwAAgrwAAIK+AACCvwAAgv4AAIMAAACDAgAAgwQAAIMHAACDCAAAgwkAAIMKAACDCwAAgw0AAIMPAACDEAAAgxEAAIMTAACDFAAAg1MAAINVAACDVwAAg1kAAINcAACDXQAAg14AAINfAACDYAAAg2IAAINkAACDZQAAg2YAAINoAACDaQAAg6gAAIOqAACDrAAAg64AAIOxAACDsgAAg7MAAIO0AACDtQAAg7cAAIO5AACDugAAg7sAAIO9AACDvgAAg+MAAIQHAACELgAAhFIAAIRVAACEVwAAhFkAAIRbAACEXQAAhF8AAIRgAACEYwAAhHAAAIR/AACEgQAAhIMAAISFAACEhwAAhIkAAISLAACEjQAAhJwAAISfAACEogAAhKUAAISoAACEqwAAhK4AAISxAACEswAAhPIAAIT0AACE9gAAhPgAAIT7AACE/AAAhP0AAIT+AACE/wAAhQEAAIUDAACFBAAAhQUAAIUHAACFCAAAhUcAAIVJAACFSwAAhU0AAIVQAACFUQAAhVIAAIVTAACFVAAAhVYAAIVYAACFWQAAhVoAAIVcAACFXQAAhZwAAIWeAACFoAAAhaIAAIWlAACFpgAAhacAAIWoAACFqQAAhasAAIWtAACFrgAAha8AAIWxAACFsgAAhfEAAIXzAACF9QAAhfcAAIX6AACF+wAAhfwAAIX9AACF/gAAhgAAAIYCAACGAwAAhgQAAIYGAACGBwAAhkYAAIZIAACGSgAAhkwAAIZPAACGUAAAhlEAAIZSAACGUwAAhlUAAIZXAACGWAAAhlkAAIZbAACGXAAAhpsAAIadAACGnwAAhqEAAIakAACGpQAAhqYAAIanAACGqAAAhqoAAIasAACGrQAAhq4AAIawAACGsQAAhvAAAIbyAACG9AAAhvYAAIb5AACG+gAAhvsAAIb8AACG/QAAhv8AAIcBAACHAgAAhwMAAIcFAACHBgAAhxEAAIcaAACHGwAAhx0AAIcmAACHMQAAh0AAAIdLAACHWQAAh24AAIeCAACHmQAAh6sAAIfqAACH7AAAh+4AAIfwAACH8gAAh/MAAIf0AACH9QAAh/YAAIf4AACH+gAAh/sAAIf8AACH/gAAh/8AAIg+AACIQAAAiEIAAIhEAACIRgAAiEcAAIhIAACISQAAiEoAAIhMAACITgAAiE8AAIhQAACIUgAAiFMAAIiSAACIlAAAiJcAAIiZAACImwAAiJwAAIidAACIngAAiJ8AAIihAACIowAAiKQAAIilAACIpwAAiKgAAIjzAACJFgAAiTYAAIlWAACJWAAAiVoAAIlcAACJXgAAiWAAAIlhAACJYgAAiWUAAIlmAACJaAAAiWkAAIlrAACJbQAAiW4AAIlvAACJcgAAiXMAAIl4AACJhQAAiYoAAImMAACJjgAAiZMAAImWAACJmQAAiZsAAInAAACJ5AAAigsAAIovAACKMgAAijQAAIo2AACKOAAAijoAAIo8AACKPQAAikAAAIpNAACKXgAAimAAAIpiAACKZAAAimYAAIpoAACKagAAimwAAIpuAACKfwAAioIAAIqFAACKiAAAiosAAIqOAACKkQAAipQAAIqXAACKmQAAitgAAIraAACK3AAAit4AAIrhAACK4gAAiuMAAIrkAACK5QAAiucAAIrpAACK6gAAiusAAIrtAACK7gAAiy0AAIsvAACLMQAAizMAAIs2AACLNwAAizgAAIs5AACLOgAAizwAAIs+AACLPwAAi0AAAItCAACLQwAAi4IAAIuEAACLhwAAi4kAAIuMAACLjQAAi44AAIuPAACLkAAAi5IAAIuUAACLlQAAi5YAAIuYAACLmQAAi6YAAIunAACLqAAAi6oAAIvpAACL6wAAi+0AAIvvAACL8gAAi/MAAIv0AACL9QAAi/YAAIv4AACL+gAAi/sAAIv8AACL/gAAi/8AAIw+AACMQAAAjEIAAIxEAACMRwAAjEgAAIxJAACMSgAAjEsAAIxNAACMTwAAjFAAAIxRAACMUwAAjFQAAIyTAACMlQAAjJcAAIyZAACMnAAAjJ0AAIyeAACMnwAAjKAAAIyiAACMpAAAjKUAAIymAACMqAAAjKkAAIzoAACM6gAAjOwAAIzuAACM8QAAjPIAAIzzAACM9AAAjPUAAIz3AACM+QAAjPoAAIz7AACM/QAAjP4AAI09AACNPwAAjUEAAI1DAACNRgAAjUcAAI1IAACNSQAAjUoAAI1MAACNTgAAjU8AAI1QAACNUgAAjVMAAI14AACNnAAAjcMAAI3nAACN6gAAjewAAI3uAACN8AAAjfIAAI30AACN9QAAjfgAAI4FAACOFAAAjhYAAI4YAACOGgAAjhwAAI4eAACOIAAAjiIAAI4xAACONAAAjjcAAI46AACOPQAAjkAAAI5DAACORgAAjkgAAI6HAACOiQAAjowAAI6OAACOkQAAjpIAAI6TAACOlAAAjpUAAI6XAACOmQAAjpoAAI6bAACOnQAAjp4AAI6iAACO4QAAjuMAAI7lAACO5wAAjuoAAI7rAACO7AAAju0AAI7uAACO8AAAjvIAAI7zAACO9AAAjvYAAI73AACPNgAAjzgAAI86AACPPAAAjz8AAI9AAACPQQAAj0IAAI9DAACPRQAAj0cAAI9IAACPSQAAj0sAAI9MAACPiwAAj40AAI+QAACPkgAAj5UAAI+WAACPlwAAj5gAAI+ZAACPmwAAj50AAI+eAACPnwAAj6EAAI+iAACPpQAAj+QAAI/mAACP6AAAj+oAAI/tAACP7gAAj+8AAI/wAACP8QAAj/MAAI/1AACP9gAAj/cAAI/5AACP+gAAkDkAAJA7AACQPQAAkD8AAJBCAACQQwAAkEQAAJBFAACQRgAAkEgAAJBKAACQSwAAkEwAAJBOAACQTwAAkI4AAJCQAACQkgAAkJQAAJCXAACQmAAAkJkAAJCaAACQmwAAkJ0AAJCfAACQoAAAkKEAAJCjAACQpAAAkO8AAJESAACRMgAAkVIAAJFUAACRVgAAkVgAAJFaAACRXAAAkV0AAJFeAACRYQAAkWIAAJFkAACRZQAAkWcAAJFpAACRagAAkWsAAJFuAACRbwAAkXQAAJGBAACRhgAAkYgAAJGKAACRjwAAkZIAAJGVAACRlwAAkbwAAJHgAACSBwAAkisAAJIuAACSMAAAkjIAAJI0AACSNgAAkjgAAJI5AACSPAAAkkkAAJJaAACSXAAAkl4AAJJgAACSYgAAkmQAAJJmAACSaAAAkmoAAJJ7AACSfgAAkoEAAJKEAACShwAAkooAAJKNAACSkAAAkpMAAJKVAACS1AAAktYAAJLYAACS2gAAkt0AAJLeAACS3wAAkuAAAJLhAACS4wAAkuUAAJLmAACS5wAAkukAAJLqAACTKQAAkysAAJMtAACTLwAAkzIAAJMzAACTNAAAkzUAAJM2AACTOAAAkzoAAJM7AACTPAAAkz4AAJM/AACTfgAAk4AAAJODAACThQAAk4gAAJOJAACTigAAk4sAAJOMAACTjgAAk5AAAJORAACTkgAAk5QAAJOVAACTogAAk6MAAJOkAACTpgAAk+UAAJPnAACT6QAAk+sAAJPuAACT7wAAk/AAAJPxAACT8gAAk/QAAJP2AACT9wAAk/gAAJP6AACT+wAAlDoAAJQ8AACUPgAAlEAAAJRDAACURAAAlEUAAJRGAACURwAAlEkAAJRLAACUTAAAlE0AAJRPAACUUAAAlI8AAJSRAACUkwAAlJUAAJSYAACUmQAAlJoAAJSbAACUnAAAlJ4AAJSgAACUoQAAlKIAAJSkAACUpQAAlOQAAJTmAACU6AAAlOoAAJTtAACU7gAAlO8AAJTwAACU8QAAlPMAAJT1AACU9gAAlPcAAJT5AACU+gAAlTkAAJU7AACVPQAAlT8AAJVCAACVQwAAlUQAAJVFAACVRgAAlUgAAJVKAACVSwAAlUwAAJVOAACVTwAAlXQAAJWYAACVvwAAleMAAJXmAACV6AAAleoAAJXsAACV7gAAlfAAAJXxAACV9AAAlgEAAJYQAACWEgAAlhQAAJYWAACWGAAAlhoAAJYcAACWHgAAli0AAJYwAACWMwAAljYAAJY5AACWPAAAlj8AAJZCAACWRAAAloMAAJaFAACWiAAAlooAAJaNAACWjgAAlo8AAJaQAACWkQAAlpMAAJaVAACWlgAAlpcAAJaZAACWmgAAltkAAJbbAACW3QAAlt8AAJbiAACW4wAAluQAAJblAACW5gAAlugAAJbqAACW6wAAluwAAJbuAACW7wAAly4AAJcwAACXMgAAlzQAAJc3AACXOAAAlzkAAJc6AACXOwAAlz0AAJc/AACXQAAAl0EAAJdDAACXRAAAl4MAAJeFAACXiAAAl4oAAJeNAACXjgAAl48AAJeQAACXkQAAl5MAAJeVAACXlgAAl5cAAJeZAACXmgAAl9kAAJfbAACX3QAAl98AAJfiAACX4wAAl+QAAJflAACX5gAAl+gAAJfqAACX6wAAl+wAAJfuAACX7wAAmC4AAJgwAACYMgAAmDQAAJg3AACYOAAAmDkAAJg6AACYOwAAmD0AAJg/AACYQAAAmEEAAJhDAACYRAAAmIMAAJiFAACYhwAAmIkAAJiMAACYjQAAmI4AAJiPAACYkAAAmJIAAJiUAACYlQAAmJYAAJiYAACYmQAAmOQAAJkHAACZJwAAmUcAAJlJAACZSwAAmU0AAJlPAACZUQAAmVIAAJlTAACZVgAAmVcAAJlZAACZWgAAmVwAAJleAACZXwAAmWAAAJljAACZZAAAmW0AAJl6AACZfwAAmYEAAJmDAACZiAAAmYsAAJmOAACZkAAAmbUAAJnZAACaAAAAmiQAAJonAACaKQAAmisAAJotAACaLwAAmjEAAJoyAACaNQAAmkIAAJpTAACaVQAAmlcAAJpZAACaWwAAml0AAJpfAACaYQAAmmMAAJp0AACadwAAmnoAAJp9AACagAAAmoMAAJqGAACaiQAAmowAAJqOAACazQAAms8AAJrRAACa0wAAmtYAAJrXAACa2AAAmtkAAJraAACa3AAAmt4AAJrfAACa4AAAmuIAAJrjAACbIgAAmyQAAJsmAACbKAAAmysAAJssAACbLQAAmy4AAJsvAACbMQAAmzMAAJs0AACbNQAAmzcAAJs4AACbdwAAm3kAAJt8AACbfgAAm4EAAJuCAACbgwAAm4QAAJuFAACbhwAAm4kAAJuKAACbiwAAm40AAJuOAACbmwAAm5wAAJudAACbnwAAm94AAJvgAACb4gAAm+QAAJvnAACb6AAAm+kAAJvqAACb6wAAm+0AAJvvAACb8AAAm/EAAJvzAACb9AAAnDMAAJw1AACcNwAAnDkAAJw8AACcPQAAnD4AAJw/AACcQAAAnEIAAJxEAACcRQAAnEYAAJxIAACcSQAAnIgAAJyKAACcjAAAnI4AAJyRAACckgAAnJMAAJyUAACclQAAnJcAAJyZAACcmgAAnJsAAJydAACcngAAnN0AAJzfAACc4QAAnOMAAJzmAACc5wAAnOgAAJzpAACc6gAAnOwAAJzuAACc7wAAnPAAAJzyAACc8wAAnTIAAJ00AACdNgAAnTgAAJ07AACdPAAAnT0AAJ0+AACdPwAAnUEAAJ1DAACdRAAAnUUAAJ1HAACdSAAAnW0AAJ2RAACduAAAndwAAJ3fAACd4QAAneMAAJ3lAACd5wAAnekAAJ3qAACd7QAAnfoAAJ4JAACeCwAAng0AAJ4PAACeEQAAnhMAAJ4VAACeFwAAniYAAJ4pAACeLAAAni8AAJ4yAACeNQAAnjgAAJ47AACePQAAnnwAAJ5+AACegAAAnoIAAJ6FAACehgAAnocAAJ6IAACeiQAAnosAAJ6NAACejgAAno8AAJ6RAACekgAAntEAAJ7TAACe1QAAntcAAJ7aAACe2wAAntwAAJ7dAACe3gAAnuAAAJ7iAACe4wAAnuQAAJ7mAACe5wAAnyYAAJ8oAACfKgAAnywAAJ8vAACfMAAAnzEAAJ8yAACfMwAAnzUAAJ83AACfOAAAnzkAAJ87AACfPAAAn3sAAJ99AACffwAAn4EAAJ+EAACfhQAAn4YAAJ+HAACfiAAAn4oAAJ+MAACfjQAAn44AAJ+QAACfkQAAn9AAAJ/SAACf1AAAn9YAAJ/ZAACf2gAAn9sAAJ/cAACf3QAAn98AAJ/hAACf4gAAn+MAAJ/lAACf5gAAoCUAAKAnAACgKQAAoCsAAKAuAACgLwAAoDAAAKAxAACgMgAAoDQAAKA2AACgNwAAoDgAAKA6AACgOwAAoHoAAKB8AACgfgAAoIAAAKCDAACghAAAoIUAAKCGAACghwAAoIkAAKCLAACgjAAAoI0AAKCPAACgkAAAoNsAAKD+AAChHgAAoT4AAKFAAAChQgAAoUQAAKFGAAChSAAAoUkAAKFKAAChTQAAoU4AAKFQAAChUQAAoVMAAKFVAAChVgAAoVcAAKFaAAChWwAAoWAAAKFtAAChcgAAoXQAAKF2AAChewAAoX4AAKGBAAChgwAAoagAAKHMAACh8wAAohcAAKIaAACiHAAAoh4AAKIgAACiIgAAoiQAAKIlAACiKAAAojUAAKJGAACiSAAAokoAAKJMAACiTgAAolAAAKJSAACiVAAAolYAAKJnAACiagAAom0AAKJwAACicwAAonYAAKJ5AACifAAAon8AAKKBAACiwAAAosIAAKLEAACixgAAoskAAKLKAACiywAAoswAAKLNAACizwAAotEAAKLSAACi0wAAotUAAKLWAACjFQAAoxcAAKMZAACjGwAAox4AAKMfAACjIAAAoyEAAKMiAACjJAAAoyYAAKMnAACjKAAAoyoAAKMrAACjagAAo2wAAKNvAACjcQAAo3QAAKN1AACjdgAAo3cAAKN4AACjegAAo3wAAKN9AACjfgAAo4AAAKOBAACjjgAAo48AAKOQAACjkgAAo9EAAKPTAACj1QAAo9cAAKPaAACj2wAAo9wAAKPdAACj3gAAo+AAAKPiAACj4wAAo+QAAKPmAACj5wAApCYAAKQoAACkKgAApCwAAKQvAACkMAAApDEAAKQyAACkMwAApDUAAKQ3AACkOAAApDkAAKQ7AACkPAAApHsAAKR9AACkfwAApIEAAKSEAACkhQAApIYAAKSHAACkiAAApIoAAKSMAACkjQAApI4AAKSQAACkkQAApNAAAKTSAACk1AAApNYAAKTZAACk2gAApNsAAKTcAACk3QAApN8AAKThAACk4gAApOMAAKTlAACk5gAApSUAAKUnAAClKQAApSsAAKUuAAClLwAApTAAAKUxAAClMgAApTQAAKU2AAClNwAApTgAAKU6AAClOwAApWAAAKWEAAClqwAApc8AAKXSAACl1AAApdYAAKXYAACl2gAApdwAAKXdAACl4AAApe0AAKX8AACl/gAApgAAAKYCAACmBAAApgYAAKYIAACmCgAAphkAAKYcAACmHwAApiIAAKYlAACmKAAApisAAKYuAACmMAAApm8AAKZxAACmdAAApnYAAKZ5AACmegAApnsAAKZ8AACmfQAApn8AAKaBAACmggAApoMAAKaFAACmhgAApogAAKbHAACmyQAApssAAKbNAACm0AAAptEAAKbSAACm0wAAptQAAKbWAACm2AAAptkAAKbaAACm3AAApt0AAKccAACnHgAApyAAAKciAACnJQAApyYAAKcnAACnKAAApykAAKcrAACnLQAApy4AAKcvAACnMQAApzIAAKdxAACncwAAp3YAAKd4AACnewAAp3wAAKd9AACnfgAAp38AAKeBAACngwAAp4QAAKeFAACnhwAAp4gAAKeLAACnygAAp8wAAKfOAACn0AAAp9MAAKfUAACn1QAAp9YAAKfXAACn2QAAp9sAAKfcAACn3QAAp98AAKfgAACoHwAAqCEAAKgjAACoJQAAqCgAAKgpAACoKgAAqCsAAKgsAACoLgAAqDAAAKgxAACoMgAAqDQAAKg1AACodAAAqHYAAKh4AACoegAAqH0AAKh+AACofwAAqIAAAKiBAACogwAAqIUAAKiGAACohwAAqIkAAKiKAACo1QAAqPgAAKkYAACpOAAAqToAAKk8AACpPgAAqUAAAKlCAACpQwAAqUQAAKlHAACpSAAAqUoAAKlLAACpTQAAqU8AAKlQAACpUQAAqVQAAKlVAACpWgAAqWcAAKlsAACpbgAAqXAAAKl1AACpeAAAqXsAAKl9AACpogAAqcYAAKntAACqEQAAqhQAAKoWAACqGAAAqhoAAKocAACqHgAAqh8AAKoiAACqLwAAqkAAAKpCAACqRAAAqkYAAKpIAACqSgAAqkwAAKpOAACqUAAAqmEAAKpkAACqZwAAqmoAAKptAACqcAAAqnMAAKp2AACqeQAAqnsAAKq6AACqvAAAqr4AAKrAAACqwwAAqsQAAKrFAACqxgAAqscAAKrJAACqywAAqswAAKrNAACqzwAAqtAAAKsPAACrEQAAqxMAAKsVAACrGAAAqxkAAKsaAACrGwAAqxwAAKseAACrIAAAqyEAAKsiAACrJAAAqyUAAKtkAACrZgAAq2kAAKtrAACrbgAAq28AAKtwAACrcQAAq3IAAKt0AACrdgAAq3cAAKt4AACregAAq3sAAKuIAACriQAAq4oAAKuMAACrywAAq80AAKvPAACr0QAAq9QAAKvVAACr1gAAq9cAAKvYAACr2gAAq9wAAKvdAACr3gAAq+AAAKvhAACsIAAArCIAAKwkAACsJgAArCkAAKwqAACsKwAArCwAAKwtAACsLwAArDEAAKwyAACsMwAArDUAAKw2AACsdQAArHcAAKx5AACsewAArH4AAKx/AACsgAAArIEAAKyCAACshAAArIYAAKyHAACsiAAArIoAAKyLAACsygAArMwAAKzOAACs0AAArNMAAKzUAACs1QAArNYAAKzXAACs2QAArNsAAKzcAACs3QAArN8AAKzgAACtHwAArSEAAK0jAACtJQAArSgAAK0pAACtKgAArSsAAK0sAACtLgAArTAAAK0xAACtMgAArTQAAK01AACtWgAArX4AAK2lAACtyQAArcwAAK3OAACt0AAArdIAAK3UAACt1gAArdcAAK3aAACt5wAArfYAAK34AACt+gAArfwAAK3+AACuAAAArgIAAK4EAACuEwAArhYAAK4ZAACuHAAArh8AAK4iAACuJQAArigAAK4qAACuaQAArmsAAK5uAACucAAArnMAAK50AACudQAArnYAAK53AACueQAArnsAAK58AACufQAArn8AAK6AAACuvwAArsEAAK7DAACuxQAArsgAAK7JAACuygAArssAAK7MAACuzgAArtAAAK7RAACu0gAArtQAAK7VAACvFAAArxYAAK8YAACvGgAArx0AAK8eAACvHwAAryAAAK8hAACvIwAAryUAAK8mAACvJwAArykAAK8qAACvaQAAr2sAAK9uAACvcAAAr3MAAK90AACvdQAAr3YAAK93AACveQAAr3sAAK98AACvfQAAr38AAK+AAACvvwAAr8EAAK/DAACvxQAAr8gAAK/JAACvygAAr8sAAK/MAACvzgAAr9AAAK/RAACv0gAAr9QAAK/VAACwFAAAsBYAALAYAACwGgAAsB0AALAeAACwHwAAsCAAALAhAACwIwAAsCUAALAmAACwJwAAsCkAALAqAACwaQAAsGsAALBtAACwbwAAsHIAALBzAACwdAAAsHUAALB2AACweAAAsHoAALB7AACwfAAAsH4AALB/AACwygAAsO0AALENAACxLQAAsS8AALExAACxMwAAsTUAALE3AACxOAAAsTkAALE8AACxPQAAsT8AALFAAACxQgAAsUQAALFFAACxRgAAsUkAALFKAACxTwAAsVwAALFhAACxYwAAsWUAALFqAACxbQAAsXAAALFyAACxlwAAsbsAALHiAACyBgAAsgkAALILAACyDQAAsg8AALIRAACyEwAAshQAALIXAACyJAAAsjUAALI3AACyOQAAsjsAALI9AACyPwAAskEAALJDAACyRQAAslYAALJZAACyXAAAsl8AALJiAACyZQAAsmgAALJrAACybgAAsnAAALKvAACysQAAsrMAALK1AACyuAAAsrkAALK6AACyuwAAsrwAALK+AACywAAAssEAALLCAACyxAAAssUAALMEAACzBgAAswgAALMKAACzDQAAsw4AALMPAACzEAAAsxEAALMTAACzFQAAsxYAALMXAACzGQAAsxoAALNZAACzWwAAs14AALNgAACzYwAAs2QAALNlAACzZgAAs2cAALNpAACzawAAs2wAALNtAACzbwAAs3AAALN9AACzfgAAs38AALOBAACzwAAAs8IAALPEAACzxgAAs8kAALPKAACzywAAs8wAALPNAACzzwAAs9EAALPSAACz0wAAs9UAALPWAAC0FQAAtBcAALQZAAC0GwAAtB4AALQfAAC0IAAAtCEAALQiAAC0JAAAtCYAALQnAAC0KAAAtCoAALQrAAC0agAAtGwAALRuAAC0cAAAtHMAALR0AAC0dQAAtHYAALR3AAC0eQAAtHsAALR8AAC0fQAAtH8AALSAAAC0vwAAtMEAALTDAAC0xQAAtMgAALTJAAC0ygAAtMsAALTMAAC0zgAAtNAAALTRAAC00gAAtNQAALTVAAC1FAAAtRYAALUYAAC1GgAAtR0AALUeAAC1HwAAtSAAALUhAAC1IwAAtSUAALUmAAC1JwAAtSkAALUqAAC1TwAAtXMAALWaAAC1vgAAtcEAALXDAAC1xQAAtccAALXJAAC1ywAAtcwAALXPAAC13AAAtesAALXtAAC17wAAtfEAALXzAAC19QAAtfcAALX5AAC2CAAAtgs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AAMEyAADBMwAAwTQAAME3AADBOAAAwT0AAMFKAADBTwAAwVEAAMFTAADBWAAAwVsAAMFeAADBYAAAwYUAAMGpAADB0AAAwfQAAMH3AADB+QAAwfsAAMH9AADB/wAAwgEAAMICAADCBQAAwhIAAMIjAADCJQAAwicAAMIpAADCKwAAwi0AAMIvAADCMQAAwjMAAMJEAADCRwAAwkoAAMJNAADCUAAAwlMAAMJWAADCWQAAwlwAAMJeAADCnQAAwp8AAMKhAADCowAAwqYAAMKnAADCqAAAwqkAAMKqAADCrAAAwq4AAMKvAADCsAAAwrIAAMKzAADC8gAAwvQAAML2AADC+AAAwvsAAML8AADC/QAAwv4AAML/AADDAQAAwwMAAMMEAADDBQAAwwcAAMMIAADDRwAAw0kAAMNMAADDTgAAw1EAAMNSAADDUwAAw1QAAMNVAADDVwAAw1kAAMNaAADDWwAAw10AAMNeAADDawAAw2wAAMNtAADDbwAAw64AAMOwAADDsgAAw7QAAMO3AADDuAAAw7kAAMO6AADDuwAAw70AAMO/AADDwAAAw8EAAMPDAADDxAAAxAMAAMQFAADEBwAAxAkAAMQMAADEDQAAxA4AAMQPAADEEAAAxBIAAMQUAADEFQAAxBYAAMQYAADEGQAAxFgAAMRaAADEXAAAxF4AAMRhAADEYgAAxGMAAMRkAADEZQAAxGcAAMRpAADEagAAxGsAAMRtAADEbgAAxK0AAMSvAADEsQAAxLMAAMS2AADEtwAAxLgAAMS5AADEugAAxLwAAMS+AADEvwAAxMAAAMTCAADEwwAAxQIAAMUEAADFBgAAxQgAAMULAADFDAAAxQ0AAMUOAADFDwAAxREAAMUTAADFFAAAxRUAAMUXAADFGAAAxT0AAMVhAADFiAAAxawAAMWvAADFsQAAxbMAAMW1AADFtwAAxbkAAMW6AADFvQAAxcoAAMXZAADF2wAAxd0AAMXfAADF4QAAxeMAAMXlAADF5wAAxfYAAMX5AADF/AAAxf8AAMYCAADGBQAAxggAAMYLAADGDQAAxkwAAMZOAADGUAAAxlIAAMZVAADGVgAAxlcAAMZYAADGWQAAxlsAAMZdAADGXgAAxl8AAMZhAADGYgAAxqEAAMajAADGpQAAxqcAAMaqAADGqwAAxqwAAMatAADGrgAAxrAAAMayAADGswAAxrQAAMa2AADGtwAAxvYAAMb4AADG+gAAxvwAAMb/AADHAAAAxwEAAMcCAADHAwAAxwUAAMcHAADHCAAAxwkAAMcLAADHDAAAx0sAAMdNAADHUAAAx1IAAMdVAADHVgAAx1cAAMdYAADHWQAAx1sAAMddAADHXgAAx18AAMdhAADHYgAAx6UAAMfJAADH7QAAyBAAAMg3AADIVwAAyH4AAMilAADIxQAAyOkAAMkNAADJDwAAyRIAAMkUAADJFgAAyRgAAMkbAADJHgAAySAAAMkiAADJJQAAyScAAMkpAADJLAAAyS8AAMkwAADJNQAAyUIAAMlFAADJRwAAyUoAAMlNAADJTwAAyXQAAMmYAADJvwAAyeMAAMnmAADJ6AAAyeoAAMnsAADJ7gAAyfAAAMnxAADJ9AAAygEAAMoUAADKFgAAyhgAAMoaAADKHAAAyh4AAMogAADKIgAAyiQAAMomAADKOQAAyjwAAMo/AADKQgAAykUAAMpIAADKSwAAyk4AAMpRAADKVAAAylYAAMqVAADKlwAAypoAAMqcAADKnwAAyqAAAMqhAADKogAAyqMAAMqlAADKpwAAyqgAAMqpAADKqwAAyqwAAMq1AADKtgAAyrgAAMr3AADK+QAAyvsAAMr9AADLAAAAywEAAMsCAADLAwAAywQAAMsGAADLCAAAywkAAMsKAADLDAAAyw0AAMtMAADLTgAAy1EAAMtTAADLVgAAy1cAAMtYAADLWQAAy1oAAMtcAADLXgAAy18AAMtgAADLYgAAy2MAAMtsAADLbQAAy28AAMuuAADLsAAAy7IAAMu0AADLtwAAy7gAAMu5AADLugAAy7sAAMu9AADLvwAAy8AAAMvBAADLwwAAy8QAAMwDAADMBQAAzAgAAMwKAADMDQAAzA4AAMwPAADMEAAAzBEAAMwTAADMFQAAzBYAAMwXAADMGQAAzBoAAMwjAADMJAAAzCYAAMxlAADMZwAAzGkAAMxrAADMbgAAzG8AAMxwAADMcQAAzHIAAMx0AADMdgAAzHcAAMx4AADMegAAzHsAAMy6AADMvAAAzL8AAMzBAADMxAAAzMUAAMzGAADMxwAAzMgAAMzKAADMzAAAzM0AAMzOAADM0AAAzNEAAMzeAADM3wAAzOAAAMziAADNIQAAzSMAAM0lAADNJwAAzSoAAM0rAADNLAAAzS0AAM0uAADNMAAAzTIAAM0zAADNNAAAzTYAAM03AADNdgAAzXgAAM16AADNfAAAzX8AAM2AAADNgQAAzYIAAM2DAADNhQAAzYcAAM2IAADNiQAAzYsAAM2MAADNmQAAzaIAAM2lAADNpwAAzaoAAM2tAADNtgAAzbkAAM28AADNvwAAzcIAAM3EAADN0wAAzeUAAM3yAADOPQAAzmAAAM6AAADOoAAAzqIAAM6kAADOpgAAzqgAAM6rAADOrAAAzq0AAM6wAADOsQAAzrMAAM60AADOtgAAzrkAAM66AADOuwAAzr4AAM6/AADOxAAAztEAAM7WAADO2AAAztoAAM7fAADO4gAAzuUAAM7nAADPDAAAzzAAAM9XAADPewAAz34AAM+AAADPggAAz4QAAM+GAADPiAAAz4kAAM+MAADPmQAAz6oAAM+sAADPrgAAz7AAAM+yAADPtAAAz7YAAM+4AADPugAAz8sAAM/OAADP0QAAz9QAAM/XAADP2gAAz90AAM/gAADP4wAAz+UAANAkAADQJgAA0CgAANAqAADQLQAA0C4AANAvAADQMAAA0DEAANAzAADQNQAA0DYAANA3AADQOQAA0DoAANB5AADQewAA0H0AANB/AADQggAA0IMAANCEAADQhQAA0IYAANCIAADQigAA0IsAANCMAADQjgAA0I8AANDOAADQ0AAA0NMAANDVAADQ2AAA0NkAANDaAADQ2wAA0NwAANDeAADQ4AAA0OEAANDiAADQ5AAA0OUAANDyAADQ8wAA0PQAAND2AADRNQAA0TcAANE5AADROwAA0T4AANE/AADRQAAA0UEAANFCAADRRAAA0UYAANFHAADRSAAA0UoAANFLAADRigAA0YwAANGOAADRkAAA0ZMAANGUAADRlQAA0ZYAANGXAADRmQAA0ZsAANGcAADRnQAA0Z8AANGgAADR3wAA0eEAANHjAADR5QAA0egAANHpAADR6gAA0esAANHsAADR7gAA0fAAANHxAADR8gAA0fQAANH1AADSNAAA0jYAANI4AADSOgAA0j0AANI+AADSPwAA0kAAANJBAADSQwAA0kUAANJGAADSRwAA0kkAANJKAADSiQAA0osAANKNAADSjwAA0pIAANKTAADSlAAA0pUAANKWAADSmAAA0poAANKbAADSnAAA0p4AANKfAADSxAAA0ugAANMPAADTMwAA0zYAANM4AADTOgAA0zwAANM+AADTQAAA00EAANNEAADTUQAA02AAANNiAADTZAAA02YAANNoAADTagAA02wAANNuAADTfQAA04AAANODAADThgAA04kAANOMAADTjwAA05IAANOUAADT0wAA09UAANPXAADT2QAA09wAANPdAADT3gAA098AANPgAADT4gAA0+QAANPlAADT5gAA0+gAANPpAADUKAAA1CoAANQsAADULgAA1DEAANQyAADUMwAA1DQAANQ1AADUNwAA1DkAANQ6AADUOwAA1D0AANQ+AADUfQAA1H8AANSBAADUgwAA1IYAANSHAADUiAAA1IkAANSKAADUjAAA1I4AANSPAADUkAAA1JIAANSTAADU0gAA1NQAANTWAADU2AAA1NsAANTcAADU3QAA1N4AANTfAADU4QAA1OMAANTkAADU5QAA1OcAANToAADVJwAA1SkAANUrAADVLQAA1TAAANUxAADVMgAA1TMAANU0AADVNgAA1TgAANU5AADVOgAA1TwAANU9AADVfAAA1X4AANWAAADVggAA1YUAANWGAADVhwAA1YgAANWJAADViwAA1Y0AANWOAADVjwAA1ZEAANWSAADV0QAA1dMAANXWAADV2AAA1dsAANXcAADV3QAA1d4AANXfAADV4QAA1eMAANXkAADV5QAA1ecAANXoAADWMwAA1lYAANZ2AADWlgAA1pgAANaaAADWnAAA1p4AANahAADWogAA1qMAANamAADWpwAA1qkAANaqAADWrAAA1q4AANavAADWsAAA1rMAANa0AADWuQAA1sYAANbLAADWzQAA1s8AANbUAADW1wAA1toAANbcAADXAQAA1yUAANdMAADXcAAA13MAANd1AADXdwAA13kAANd7AADXfQAA134AANeBAADXjgAA158AANehAADXowAA16UAANenAADXqQAA16sAANetAADXrwAA18AAANfDAADXxgAA18kAANfMAADXzwAA19IAANfVAADX2AAA19oAANgZAADYGwAA2B0AANgfAADYIgAA2CMAANgkAADYJQAA2CYAANgoAADYKgAA2CsAANgsAADYLgAA2C8AANhuAADYcAAA2HIAANh0AADYdwAA2HgAANh5AADYegAA2HsAANh9AADYfwAA2IAAANiBAADYgwAA2IQAANjDAADYxQAA2MgAANjKAADYzQAA2M4AANjPAADY0AAA2NEAANjTAADY1QAA2NYAANjXAADY2QAA2NoAANjnAADY6AAA2OkAANjrAADZKgAA2SwAANkuAADZMAAA2TMAANk0AADZNQAA2TYAANk3AADZOQAA2TsAANk8AADZPQAA2T8AANlAAADZfwAA2YEAANmDAADZhQAA2YgAANmJAADZigAA2YsAANmMAADZjgAA2ZAAANmRAADZkgAA2ZQAANmVAADZ1AAA2dYAANnYAADZ2gAA2d0AANneAADZ3wAA2eAAANnhAADZ4wAA2eUAANnmAADZ5wAA2ekAANnqAADaKQAA2isAANotAADaLwAA2jIAANozAADaNAAA2jUAANo2AADaOAAA2joAANo7AADaPAAA2j4AANo/AADafgAA2oAAANqCAADahAAA2ocAANqIAADaiQAA2ooAANqLAADajQAA2o8AANqQAADakQAA2pMAANqUAADauQAA2t0AANsEAADbKAAA2ysAANstAADbLwAA2zEAANszAADbNQAA2zYAANs5AADbRgAA21UAANtXAADbWQAA21sAANtdAADbXwAA22EAANtjAADbcgAA23UAANt4AADbewAA234AANuBAADbhAAA24cAANuJAADbyAAA28oAANvMAADbzgAA29EAANvSAADb0wAA29QAANvVAADb1wAA29kAANvaAADb2wAA290AANveAADcHQAA3B8AANwhAADcIwAA3CYAANwnAADcKAAA3CkAANwqAADcLAAA3C4AANwvAADcMAAA3DIAANwzAADccgAA3HQAANx2AADceAAA3HsAANx8AADcfQAA3H4AANx/AADcgQAA3IMAANyEAADchQAA3IcAANyIAADcxwAA3MkAANzLAADczQAA3NAAANzRAADc0gAA3NMAANzUAADc1gAA3NgAANzZAADc2gAA3NwAANzdAADdHAAA3R4AAN0gAADdIgAA3SUAAN0mAADdJwAA3SgAAN0pAADdKwAA3S0AAN0uAADdLwAA3TEAAN0yAADdcQAA3XMAAN11AADddwAA3XoAAN17AADdfAAA3X0AAN1+AADdgAAA3YIAAN2DAADdhAAA3YYAAN2HAADdxgAA3cgAAN3LAADdzQAA3dAAAN3RAADd0gAA3dMAAN3UAADd1gAA3dgAAN3ZAADd2gAA3dwAAN3dAADeKAAA3ksAAN5rAADeiwAA3o0AAN6PAADekQAA3pMAAN6VAADelgAA3pcAAN6aAADemwAA3p0AAN6eAADeoAAA3qIAAN6jAADepAAA3qcAAN6oAADerQAA3roAAN6/AADewQAA3sMAAN7IAADeywAA3s4AAN7QAADe9QAA3xkAAN9AAADfZAAA32cAAN9pAADfawAA320AAN9vAADfcQAA33IAAN91AADfggAA35MAAN+VAADflwAA35kAAN+bAADfnQAA358AAN+hAADfowAA37QAAN+3AADfugAA370AAN/AAADfwwAA38YAAN/JAADfzAAA384AAOANAADgDwAA4BEAAOATAADgFgAA4BcAAOAYAADgGQAA4BoAAOAcAADgHgAA4B8AAOAgAADgIgAA4CMAAOBiAADgZAAA4GYAAOBoAADgawAA4GwAAOBtAADgbgAA4G8AAOBxAADgcwAA4HQAAOB1AADgdwAA4HgAAOC3AADguQAA4LwAAOC+AADgwQAA4MIAAODDAADgxAAA4MUAAODHAADgyQAA4MoAAODLAADgzQAA4M4AAODbAADg3AAA4N0AAODfAADhHgAA4SAAAOEiAADhJAAA4ScAAOEoAADhKQAA4SoAAOErAADhLQAA4S8AAOEwAADhMQAA4TMAAOE0AADhcwAA4XUAAOF3AADheQAA4XwAAOF9AADhfgAA4X8AAOGAAADhggAA4YQAAOGFAADhhgAA4YgAAOGJAADhyAAA4coAAOHMAADhzgAA4dEAAOHSAADh0wAA4dQAAOHVAADh1wAA4dkAAOHaAADh2wAA4d0AAOHeAADiHQAA4h8AAOIhAADiIwAA4iYAAOInAADiKAAA4ikAAOIqAADiLAAA4i4AAOIvAADiMAAA4jIAAOIzAADicgAA4nQAAOJ2AADieAAA4nsAAOJ8AADifQAA4n4AAOJ/AADigQAA4oMAAOKEAADihQAA4ocAAOKIAADirQAA4tEAAOL4AADjHAAA4x8AAOMhAADjIwAA4yUAAOMnAADjKQAA4yoAAOMtAADjOgAA40kAAONLAADjTQAA408AAONRAADjUwAA41UAAONXAADjZgAA42kAAONsAADjbwAA43IAAON1AADjeAAA43sAAON9AADjvAAA474AAOPAAADjwgAA48UAAOPGAADjxwAA48gAAOPJAADjywAA480AAOPOAADjzwAA49EAAOPSAADkEQAA5BMAAOQVAADkFwAA5BoAAOQbAADkHAAA5B0AAOQeAADkIAAA5CIAAOQjAADkJAAA5CYAAOQnAADkZgAA5GgAAORqAADkbAAA5G8AAORwAADkcQAA5HIAAORzAADkdQAA5HcAAOR4AADkeQAA5HsAAOR8AADkuwAA5L0AAOTAAADkwgAA5MUAAOTGAADkxwAA5MgAAOTJAADkywAA5M0AAOTOAADkzwAA5NEAAOTSAADlEQAA5RMAAOUVAADlFwAA5RoAAOUbAADlHAAA5R0AAOUeAADlIAAA5SIAAOUjAADlJAAA5SYAAOUnAADlZgAA5WgAAOVqAADlbAAA5W8AAOVwAADlcQAA5XIAAOVzAADldQAA5XcAAOV4AADleQAA5XsAAOV8AADluwAA5b0AAOXAAADlwgAA5cUAAOXGAADlxwAA5cgAAOXJAADlywAA5c0AAOXOAADlzwAA5dEAAOXSAADmHQAA5kAAAOZgAADmgAAA5oIAAOaEAADmhgAA5ogAAOaLAADmjAAA5o0AAOaQAADmkQAA5pMAAOaUAADmlgAA5pkAAOaaAADmmwAA5p4AAOafAADmqAAA5rUAAOa6AADmvAAA5r4AAObDAADmxgAA5skAAObLAADm8AAA5xQAAOc7AADnXwAA52IAAOdkAADnZgAA52gAAOdqAADnbAAA520AAOdwAADnfQAA544AAOeQAADnkgAA55QAAOeWAADnmAAA55oAAOecAADnngAA568AAOeyAADntQAA57gAAOe7AADnvgAA58EAAOfEAADnxwAA58kAAOgIAADoCgAA6AwAAOgOAADoEQAA6BIAAOgTAADoFAAA6BUAAOgXAADoGQAA6BoAAOgbAADoHQAA6B4AAOhdAADoXwAA6GEAAOhjAADoZgAA6GcAAOhoAADoaQAA6GoAAOhsAADobgAA6G8AAOhwAADocgAA6HMAAOiyAADotAAA6LcAAOi5AADovAAA6L0AAOi+AADovwAA6MAAAOjCAADoxAAA6MUAAOjGAADoyAAA6MkAAOjWAADo1wAA6NgAAOjaAADpGQAA6RsAAOkdAADpHwAA6SIAAOkjAADpJAAA6SUAAOkmAADpKAAA6SoAAOkrAADpLAAA6S4AAOkvAADpbgAA6XAAAOlyAADpdAAA6XcAAOl4AADpeQAA6XoAAOl7AADpfQAA6X8AAOmAAADpgQAA6YMAAOmEAADpwwAA6cUAAOnHAADpyQAA6cwAAOnNAADpzgAA6c8AAOnQAADp0gAA6dQAAOnVAADp1gAA6dgAAOnZAADqGAAA6hoAAOocAADqHgAA6iEAAOoiAADqIwAA6iQAAOolAADqJwAA6ikAAOoqAADqKwAA6i0AAOouAADqbQAA6m8AAOpxAADqcwAA6nYAAOp3AADqeAAA6nkAAOp6AADqfAAA6n4AAOp/AADqgAAA6oIAAOqDAADqqAAA6swAAOrzAADrFwAA6xoAAOscAADrHgAA6yAAAOsiAADrJAAA6yUAAOsoAADrNQAA60QAAOtGAADrSAAA60oAAOtMAADrTgAA61AAAOtSAADrYQAA62QAAOtnAADragAA620AAOtwAADrcwAA63YAAOt4AADrtwAA67kAAOu7AADrvQAA68AAAOvBAADrwgAA68MAAOvEAADrxgAA68gAAOvJAADrygAA68wAAOvNAADsDAAA7A4AAOwQAADsEgAA7BUAAOwWAADsFwAA7BgAAOwZAADsGwAA7B0AAOweAADsHwAA7CEAAOwiAADsYQAA7GMAAOxlAADsZwAA7GoAAOxrAADsbAAA7G0AAOxuAADscAAA7HIAAOxzAADsdAAA7HYAAOx3AADstgAA7LgAAOy7AADsvQAA7MAAAOzBAADswgAA7MMAAOzEAADsxgAA7MgAAOzJAADsygAA7MwAAOzNAADtEAAA7TQAAO1YAADtewAA7aIAAO3CAADt6QAA7hAAAO4wAADuVAAA7ngAAO56AADufQAA7n8AAO6BAADugwAA7oYAAO6JAADuiwAA7o0AAO6QAADukgAA7pQAAO6XAADumgAA7psAAO6gAADurQAA7rAAAO6yAADutQAA7rgAAO66AADu3wAA7wMAAO8qAADvTgAA71EAAO9TAADvVQAA71cAAO9ZAADvWwAA71wAAO9fAADvbAAA738AAO+BAADvgwAA74UAAO+HAADviQAA74sAAO+NAADvjwAA75EAAO+kAADvpwAA76oAAO+tAADvsAAA77MAAO+2AADvuQAA77wAAO+/AADvwQAA8AAAAPACAADwBQAA8Ac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AAQH8AAECOwABAj0AAQI/AAECQQABAkQAAQJFAAECRgABAkcAAQJIAAECSgABAkwAAQJNAAECTgABAlAAAQJRAAECkAABApIAAQKUAAEClgABApkAAQKaAAECmwABApwAAQKdAAECnwABAqEAAQKiAAECowABAqUAAQKmAAEC5QABAucAAQLpAAEC6wABAu4AAQLvAAEC8AABAvEAAQLyAAEC9AABAvYAAQL3AAEC+AABAvoAAQL7AAEDOgABAzwAAQM+AAEDQAABA0MAAQNEAAEDRQABA0YAAQNHAAEDSQABA0sAAQNMAAEDTQABA08AAQNQAAEDdQABA5kAAQPAAAED5AABA+cAAQPpAAED6wABA+0AAQPvAAED8QABA/IAAQP1AAEEAgABBBEAAQQTAAEEFQABBBcAAQQZAAEEGwABBB0AAQQfAAEELgABBDEAAQQ0AAEENwABBDoAAQQ9AAEEQAABBEMAAQRFAAEEhAABBIYAAQSIAAEEigABBI0AAQSOAAEEjwABBJAAAQSRAAEEkwABBJUAAQSWAAEElwABBJkAAQSaAAEE2QABBNsAAQTdAAEE3wABBOIAAQTjAAEE5AABBOUAAQTmAAEE6AABBOoAAQTrAAEE7AABBO4AAQTvAAEFLgABBTAAAQUyAAEFNAABBTcAAQU4AAEFOQABBToAAQU7AAEFPQABBT8AAQVAAAEFQQABBUMAAQVEAAEFgwABBYUAAQWHAAEFiQABBYwAAQWNAAEFjgABBY8AAQWQAAEFkgABBZQAAQWVAAEFlgABBZgAAQWZAAEF2AABBdoAAQXcAAEF3gABBeEAAQXiAAEF4wABBeQAAQXlAAEF5wABBekAAQXqAAEF6wABBe0AAQXuAAEGLQABBi8AAQYxAAEGMwABBjYAAQY3AAEGOAABBjkAAQY6AAEGPAABBj4AAQY/AAEGQAABBkIAAQZDAAEGggABBoQAAQaGAAEGiAABBosAAQaMAAEGjQABBo4AAQaPAAEGkQABBpMAAQaUAAEGlQABBpcAAQaYAAEG4wABBwYAAQcmAAEHRgABB0gAAQdKAAEHTAABB04AAQdRAAEHUgABB1MAAQdWAAEHVwABB1kAAQdaAAEHXAABB18AAQdgAAEHYQABB2QAAQdlAAEHbgABB3sAAQeAAAEHggABB4QAAQeJAAEHjAABB48AAQeRAAEHtgABB9oAAQgBAAEIJQABCCgAAQgqAAEILAABCC4AAQgwAAEIMgABCDMAAQg2AAEIQwABCFQAAQhWAAEIWAABCFoAAQhcAAEIXgABCGAAAQhiAAEIZAABCHUAAQh4AAEIewABCH4AAQiBAAEIhAABCIcAAQiKAAEIjQABCI8AAQjOAAEI0AABCNIAAQjUAAEI1wABCNgAAQjZAAEI2gABCNsAAQjdAAEI3wABCOAAAQjhAAEI4wABCOQAAQkjAAEJJQABCScAAQkpAAEJLAABCS0AAQkuAAEJLwABCTAAAQkyAAEJNAABCTUAAQk2AAEJOAABCTkAAQl4AAEJegABCX0AAQl/AAEJggABCYMAAQmEAAEJhQABCYYAAQmIAAEJigABCYsAAQmMAAEJjgABCY8AAQmcAAEJnQABCZ4AAQmgAAEJ3wABCeEAAQnjAAEJ5QABCegAAQnpAAEJ6gABCesAAQnsAAEJ7gABCfAAAQnxAAEJ8gABCfQAAQn1AAEKNAABCjYAAQo4AAEKOgABCj0AAQo+AAEKPwABCkAAAQpBAAEKQwABCkUAAQpGAAEKRwABCkkAAQpKAAEKiQABCosAAQqNAAEKjwABCpIAAQqTAAEKlAABCpUAAQqWAAEKmAABCpoAAQqbAAEKnAABCp4AAQqfAAEK3gABCuAAAQriAAEK5AABCucAAQroAAEK6QABCuoAAQrrAAEK7QABCu8AAQrwAAEK8QABCvMAAQr0AAELMwABCzUAAQs3AAELOQABCzwAAQs9AAELPgABCz8AAQtAAAELQgABC0QAAQtFAAELRgABC0gAAQtJAAELbgABC5IAAQu5AAEL3QABC+AAAQviAAEL5AABC+YAAQvoAAEL6gABC+sAAQvuAAEL+wABDAoAAQwMAAEMDgABDBAAAQwSAAEMFAABDBYAAQwYAAEMJwABDCoAAQwtAAEMMAABDDMAAQw2AAEMOQABDDwAAQw+AAEMfQABDH8AAQyBAAEMgwABDIYAAQyHAAEMiAABDIkAAQyKAAEMjAABDI4AAQyPAAEMkAABDJIAAQyTAAEM0gABDNQAAQzWAAEM2AABDNsAAQzcAAEM3QABDN4AAQzfAAEM4QABDOMAAQzkAAEM5QABDOcAAQzoAAENJwABDSkAAQ0rAAENLQABDTAAAQ0xAAENMgABDTMAAQ00AAENNgABDTgAAQ05AAENOgABDTwAAQ09AAENfAABDX4AAQ2AAAENggABDYUAAQ2GAAENhwABDYgAAQ2JAAENiwABDY0AAQ2OAAENjwABDZEAAQ2SAAEN0QABDdMAAQ3VAAEN1wABDdoAAQ3bAAEN3AABDd0AAQ3eAAEN4AABDeIAAQ3jAAEN5AABDeYAAQ3nAAEOJgABDigAAQ4qAAEOLAABDi8AAQ4wAAEOMQABDjIAAQ4zAAEONQABDjcAAQ44AAEOOQABDjsAAQ48AAEOewABDn0AAQ5/AAEOgQABDoQAAQ6FAAEOhgABDocAAQ6IAAEOigABDowAAQ6NAAEOjgABDpAAAQ6RAAEO3AABDv8AAQ8fAAEPPwABD0EAAQ9DAAEPRQABD0cAAQ9KAAEPSwABD0wAAQ9PAAEPUAABD1IAAQ9TAAEPVQABD1gAAQ9ZAAEPWgABD10AAQ9eAAEPZwABD3QAAQ95AAEPewABD30AAQ+CAAEPhQABD4gAAQ+KAAEPrwABD9MAAQ/6AAEQHgABECEAARAjAAEQJQABECcAARApAAEQKwABECwAARAvAAEQPAABEE0AARBPAAEQUQABEFMAARBVAAEQVwABEFkAARBbAAEQXQABEG4AARBxAAEQdAABEHcAARB6AAEQfQABEIAAARCDAAEQhgABEIgAARDHAAEQyQABEMsAARDNAAEQ0AABENEAARDSAAEQ0wABENQAARDWAAEQ2AABENkAARDaAAEQ3AABEN0AAREcAAERHgABESAAAREiAAERJQABESYAAREnAAERKAABESkAARErAAERLQABES4AAREvAAERMQABETIAARFxAAERcwABEXYAARF4AAERewABEXwAARF9AAERfgABEX8AARGBAAERgwABEYQAARGFAAERhwABEYgAARGVAAERlgABEZcAARGZAAER2AABEdoAARHcAAER3gABEeEAARHiAAER4wABEeQAARHlAAER5wABEekAARHqAAER6wABEe0AARHuAAESLQABEi8AARIxAAESMwABEjYAARI3AAESOAABEjkAARI6AAESPAABEj4AARI/AAESQAABEkIAARJDAAESggABEoQAARKGAAESiAABEosAARKMAAESjQABEo4AARKPAAESkQABEpMAARKUAAESlQABEpcAARKYAAES1wABEtkAARLcAAES3gABEuEAARLiAAES4wABEuQAARLlAAES5wABEukAARLqAAES6wABEu0AARLuAAETBAABE0MAARNFAAETRwABE0kAARNMAAETTQABE04AARNPAAETUAABE1IAARNUAAETVQABE1YAARNYAAETWQABE34AAROiAAETyQABE+0AARPwAAET8gABE/QAARP2AAET+AABE/oAARP7AAET/gABFAsAARQaAAEUHAABFB4AARQgAAEUIgABFCQAARQmAAEUKAABFDcAARQ6AAEUPQABFEAAARRDAAEURgABFEkAARRMAAEUTgABFI0AARSPAAEUkQABFJMAARSWAAEUlwABFJgAARSZAAEUmgABFJwAARSeAAEUnwABFKAAARSiAAEUowABFOIAARTkAAEU5gABFOgAARTrAAEU7AABFO0AARTuAAEU7wABFPEAARTzAAEU9AABFPUAART3AAEU+AABFTcAARU5AAEVOwABFT0AARVAAAEVQQABFUIAARVDAAEVRAABFUYAARVIAAEVSQABFUoAARVMAAEVTQABFYwAARWOAAEVkAABFZIAARWVAAEVlgABFZcAARWYAAEVmQABFZsAARWdAAEVngABFZ8AARWhAAEVogABFeEAARXjAAEV5QABFecAARXqAAEV6wABFewAARXtAAEV7gABFfAAARXyAAEV8wABFfQAARX2AAEV9wABFjYAARY4AAEWOgABFjwAARY/AAEWQAABFkEAARZCAAEWQwABFkUAARZHAAEWSAABFkkAARZLAAEWTAABFosAARaNAAEWjwABFpEAARaUAAEWlQABFpYAARaXAAEWmAABFpoAARacAAEWnQABFp4AARagAAEWoQABFuwAARcPAAEXLwABF08AARdRAAEXUwABF1UAARdXAAEXWgABF1sAARdcAAEXXwABF2AAARdiAAEXYwABF2UAARdoAAEXaQABF2oAARdtAAEXbgABF3cAAReEAAEXiQABF4sAAReNAAEXkgABF5UAAReYAAEXmgABF78AARfjAAEYCgABGC4AARgxAAEYMwABGDUAARg3AAEYOQABGDsAARg8AAEYPwABGEwAARhdAAEYXwABGGEAARhjAAEYZQABGGcAARhpAAEYawABGG0AARh+AAEYgQABGIQAARiHAAEYigABGI0AARiQAAEYkwABGJYAARiYAAEY1wABGNkAARjbAAEY3QABGOAAARjhAAEY4gABGOMAARjkAAEY5gABGOgAARjpAAEY6gABGOwAARjtAAEZLAABGS4AARkwAAEZMgABGTUAARk2AAEZNwABGTgAARk5AAEZOwABGT0AARk+AAEZPwABGUEAARlCAAEZgQABGYMAARmGAAEZiAABGYsAARmMAAEZjQABGY4AARmPAAEZkQABGZMAARmUAAEZlQABGZcAARmYAAEZpQABGaYAARmnAAEZqQABGegAARnqAAEZ7AABGe4AARnxAAEZ8gABGfMAARn0AAEZ9QABGfcAARn5AAEZ+gABGfsAARn9AAEZ/gABGj0AARo/AAEaQQABGkMAARpGAAEaRwABGkgAARpJAAEaSgABGkwAARpOAAEaTwABGlAAARpSAAEaUwABGpIAARqUAAEalgABGpgAARqbAAEanAABGp0AARqeAAEanwABGqEAARqjAAEapAABGqUAARqnAAEaqAABGucAARrpAAEa6wABGu0AARrwAAEa8QABGvIAARrzAAEa9AABGvYAARr4AAEa+QABGvoAARr8AAEa/QABGzwAARs+AAEbQAABG0IAARtFAAEbRgABG0cAARtIAAEbSQABG0sAARtNAAEbTgABG08AARtRAAEbUgABG3cAARubAAEbwgABG+YAARvpAAEb6wABG+0AARvvAAEb8QABG/MAARv0AAEb9wABHAQAARwTAAEcFQABHBcAARwZAAEcGwABHB0AARwfAAEcIQABHDAAARwzAAEcNgABHDkAARw8AAEcPwABHEIAARxFAAEcRwABHIYAARyIAAEcigABHIwAARyPAAEckAABHJEAARySAAEckwABHJUAARyXAAEcmAABHJkAARybAAEcnAABHNsAARzdAAEc3wABHOEAARzkAAEc5QABHOYAARznAAEc6AABHOoAARzsAAEc7QABHO4AARzwAAEc8QABHTAAAR0yAAEdNAABHTYAAR05AAEdOgABHTsAAR08AAEdPQABHT8AAR1BAAEdQgABHUMAAR1FAAEdRgABHYUAAR2HAAEdiQABHYsAAR2OAAEdjwABHZAAAR2RAAEdkgABHZQAAR2WAAEdlwABHZgAAR2aAAEdmwABHdoAAR3cAAEd3gABHeAAAR3jAAEd5AABHeUAAR3mAAEd5wABHekAAR3rAAEd7AABHe0AAR3vAAEd8AABHi8AAR4xAAEeMwABHjUAAR44AAEeOQABHjoAAR47AAEePAABHj4AAR5AAAEeQQABHkIAAR5EAAEeRQABHoQAAR6GAAEeiAABHooAAR6NAAEejgABHo8AAR6QAAEekQABHpMAAR6VAAEelgABHpcAAR6ZAAEemgABHuUAAR8IAAEfKAABH0gAAR9KAAEfTAABH04AAR9QAAEfUwABH1QAAR9VAAEfWAABH1kAAR9bAAEfXAABH14AAR9hAAEfYgABH2MAAR9mAAEfZwABH2wAAR95AAEffgABH4AAAR+CAAEfhwABH4oAAR+NAAEfjwABH7QAAR/YAAEf/wABICMAASAmAAEgKAABICoAASAsAAEgLgABIDAAASAxAAEgNAABIEEAASBSAAEgVAABIFYAASBYAAEgWgABIFwAASBeAAEgYAABIGIAASBzAAEgdgABIHkAASB8AAEgfwABIIIAASCFAAEgiAABIIsAASCNAAEgzAABIM4AASDQAAEg0gABINUAASDWAAEg1wABINgAASDZAAEg2wABIN0AASDeAAEg3wABIOEAASDiAAEhIQABISMAASElAAEhJwABISoAASErAAEhLAABIS0AASEuAAEhMAABITIAASEzAAEhNAABITYAASE3AAEhdgABIXgAASF7AAEhfQABIYAAASGBAAEhggABIYMAASGEAAEhhgABIYgAASGJAAEhigABIYwAASGNAAEhmgABIZsAASGcAAEhngABId0AASHfAAEh4QABIeMAASHmAAEh5wABIegAASHpAAEh6gABIewAASHuAAEh7wABIfAAASHyAAEh8wABIjIAASI0AAEiNgABIjgAASI7AAEiPAABIj0AASI+AAEiPwABIkEAASJDAAEiRAABIkUAASJHAAEiSAABIocAASKJAAEiiwABIo0AASKQAAEikQABIpIAASKTAAEilAABIpYAASKYAAEimQABIpoAASKcAAEinQABItwAASLeAAEi4AABIuIAASLlAAEi5gABIucAASLoAAEi6QABIusAASLtAAEi7gABIu8AASLxAAEi8gABIzEAASMzAAEjNQABIzcAASM6AAEjOwABIzwAASM9AAEjPgABI0AAASNCAAEjQwABI0QAASNGAAEjRwABI2wAASOQAAEjtwABI9sAASPeAAEj4AABI+IAASPkAAEj5gABI+gAASPpAAEj7AABI/kAASQIAAEkCgABJAwAASQOAAEkEAABJBIAASQUAAEkFgABJCUAASQoAAEkKwABJC4AASQxAAEkNAABJDcAASQ6AAEkPAABJHsAASR9AAEkfwABJIEAASSEAAEkhQABJIYAASSHAAEkiAABJIoAASSMAAEkjQABJI4AASSQAAEkkQABJNAAASTSAAEk1AABJNYAASTZAAEk2gABJNsAASTcAAEk3QABJN8AASThAAEk4gABJOMAASTlAAEk5gABJSUAASUnAAElKQABJSsAASUuAAElLwABJTAAASUxAAElMgABJTQAASU2AAElNwABJTgAASU6AAElOwABJXoAASV8AAElfgABJYAAASWDAAElhAABJYUAASWGAAElhwABJYkAASWLAAEljAABJY0AASWPAAElkAABJc8AASXRAAEl0wABJdUAASXYAAEl2QABJdoAASXbAAEl3AABJd4AASXgAAEl4QABJeIAASXkAAEl5QABJiQAASYmAAEmKAABJioAASYtAAEmLgABJi8AASYwAAEmMQABJjMAASY1AAEmNgABJjcAASY5AAEmOgABJnkAASZ7AAEmfQABJn8AASaCAAEmgwABJoQAASaFAAEmhgABJogAASaKAAEmiwABJowAASaOAAEmjwABJtoAASb9AAEnHQABJz0AASc/AAEnQQABJ0MAASdFAAEnSAABJ0kAASdKAAEnTQABJ04AASdQAAEnUQABJ1MAASdWAAEnVwABJ1gAASdbAAEnXAABJ2EAASduAAEncwABJ3UAASd3AAEnfAABJ38AASeCAAEnhAABJ6kAASfNAAEn9AABKBgAASgbAAEoHQABKB8AASghAAEoIwABKCUAASgmAAEoKQABKDYAAShHAAEoSQABKEsAAShNAAEoTwABKFEAAShTAAEoVQABKFcAAShoAAEoawABKG4AAShxAAEodAABKHcAASh6AAEofQABKIAAASiCAAEowQABKMMAASjFAAEoxwABKMoAASjLAAEozAABKM0AASjOAAEo0AABKNIAASjTAAEo1AABKNYAASjXAAEpFgABKRgAASkaAAEpHAABKR8AASkgAAEpIQABKSIAASkjAAEpJQABKScAASkoAAEpKQABKSsAASksAAEpawABKW0AASlwAAEpcgABKXUAASl2AAEpdwABKXgAASl5AAEpewABKX0AASl+AAEpfwABKYEAASmCAAEpjwABKZAAASmRAAEpkwABKdIAASnUAAEp1gABKdgAASnbAAEp3AABKd0AASneAAEp3wABKeEAASnjAAEp5AABKeUAASnnAAEp6AABKicAASopAAEqKwABKi0AASowAAEqMQABKjIAASozAAEqNAABKjYAASo4AAEqOQABKjoAASo8AAEqPQABKnwAASp+AAEqgAABKoIAASqFAAEqhgABKocAASqIAAEqiQABKosAASqNAAEqjgABKo8AASqRAAEqkgABKtEAASrTAAEq1QABKtcAASraAAEq2wABKtwAASrdAAEq3gABKuAAASriAAEq4wABKuQAASrmAAEq5wABKyYAASsoAAErKgABKywAASsvAAErMAABKzEAASsyAAErMwABKzUAASs3AAErOAABKzkAASs7AAErPAABK2EAASuFAAErrAABK9AAASvTAAEr1QABK9cAASvZAAEr2wABK90AASveAAEr4QABK+4AASv9AAEr/wABLAEAASwDAAEsBQABLAcAASwJAAEsCwABLBoAASwdAAEsIAABLCMAASwmAAEsKQABLCwAASwvAAEsMQABLHAAASxyAAEsdAABLHYAASx5AAEsegABLHsAASx8AAEsfQABLH8AASyBAAEsggABLIMAASyFAAEshgABLMUAASzHAAEsyQABLMsAASzOAAEszwABLNAAASzRAAEs0gABLNQAASzWAAEs1wABLNgAASzaAAEs2wABLRoAAS0cAAEtHgABLSAAAS0jAAEtJAABLSUAAS0mAAEtJwABLSkAAS0rAAEtLAABLS0AAS0vAAEtMAABLW8AAS1xAAEtcwABLXUAAS14AAEteQABLXoAAS17AAEtfAABLX4AAS2AAAEtgQABLYIAAS2EAAEthQABLcQAAS3GAAEtyAABLcoAAS3NAAEtzgABLc8AAS3QAAEt0QABLdMAAS3VAAEt1gABLdcAAS3ZAAEt2gABLhkAAS4bAAEuHQABLh8AAS4iAAEuIwABLiQAAS4lAAEuJgABLigAAS4qAAEuKwABLiwAAS4uAAEuLwABLm4AAS5wAAEucgABLnQAAS53AAEueAABLnkAAS56AAEuewABLn0AAS5/AAEugAABLoEAAS6DAAEuhAABLs8AAS7yAAEvEgABLzIAAS80AAEvNgABLzgAAS86AAEvPQABLz4AAS8/AAEvQgABL0MAAS9FAAEvRgABL0gAAS9LAAEvTAABL00AAS9QAAEvUQABL1YAAS9jAAEvaAABL2oAAS9sAAEvcQABL3QAAS93AAEveQABL54AAS/CAAEv6QABMA0AATAQAAEwEgABMBQAATAWAAEwGAABMBoAATAbAAEwHgABMCsAATA8AAEwPgABMEAAATBCAAEwRAABMEYAATBIAAEwSgABMEwAATBdAAEwYAABMGMAATBmAAEwaQABMGwAATBvAAEwcgABMHUAATB3AAEwtgABMLgAATC6AAEwvAABML8AATDAAAEwwQABMMIAATDDAAEwxQABMMcAATDIAAEwyQABMMsAATDMAAExCwABMQ0AATEPAAExEQABMRQAATEVAAExFgABMRcAATEYAAExGgABMRwAATEdAAExHgABMSAAATEhAAExYAABMWIAATFlAAExZwABMWoAATFrAAExbAABMW0AATFuAAExcAABMXIAATFzAAExdAABMXYAATF3AAExhAABMYUAATGGAAExiAABMccAATHJAAExywABMc0AATHQAAEx0QABMdIAATHTAAEx1AABMdYAATHYAAEx2QABMdoAATHcAAEx3QABMhwAATIeAAEyIAABMiIAATIlAAEyJgABMicAATIoAAEyKQABMisAATItAAEyLgABMi8AATIxAAEyMgABMnEAATJzAAEydQABMncAATJ6AAEyewABMnwAATJ9AAEyfgABMoAAATKCAAEygwABMoQAATKGAAEyhwABMsYAATLIAAEyygABMswAATLPAAEy0AABMtEAATLSAAEy0wABMtUAATLXAAEy2AABMtkAATLbAAEy3AABMxsAATMdAAEzHwABMyEAATMkAAEzJQABMyYAATMnAAEzKAABMyoAATMsAAEzLQABMy4AATMwAAEzMQABM1YAATN6AAEzoQABM8UAATPIAAEzygABM8wAATPOAAEz0AABM9IAATPTAAEz1gABM+MAATPyAAEz9AABM/YAATP4AAEz+gABM/wAATP+AAE0AAABNA8AATQSAAE0FQABNBgAATQbAAE0HgABNCEAATQkAAE0JgABNGUAATRnAAE0aQABNGsAATRuAAE0bwABNHAAATRxAAE0cgABNHQAATR2AAE0dwABNHgAATR6AAE0ewABNLoAATS8AAE0vgABNMAAATTDAAE0xAABNMUAATTGAAE0xwABNMkAATTLAAE0zAABNM0AATTPAAE00AABNQ8AATURAAE1EwABNRUAATUYAAE1GQABNRoAATUbAAE1HAABNR4AATUgAAE1IQABNSIAATUkAAE1JQABNWQAATVmAAE1aAABNWoAATVtAAE1bgABNW8AATVwAAE1cQABNXMAATV1AAE1dgABNXcAATV5AAE1egABNbkAATW7AAE1vQABNb8AATXCAAE1wwABNcQAATXFAAE1xgABNcgAATXKAAE1ywABNcwAATXOAAE1zwABNg4AATYQAAE2EgABNhQAATYXAAE2GAABNhkAATYaAAE2GwABNh0AATYfAAE2IAABNiEAATYjAAE2JAABNmMAATZlAAE2ZwABNmkAATZsAAE2bQABNm4AATZvAAE2cAABNnIAATZ0AAE2dQABNnYAATZ4AAE2eQABNsQAATbnAAE3BwABNycAATcpAAE3KwABNy0AATcvAAE3MgABNzMAATc0AAE3NwABNzgAATc6AAE3OwABNz0AATdAAAE3QQABN0IAATdFAAE3RgABN0sAATdYAAE3XQABN18AATdhAAE3ZgABN2kAATdsAAE3bgABN5MAATe3AAE33gABOAIAATgFAAE4BwABOAkAATgLAAE4DQABOA8AATgQAAE4EwABOCAAATgxAAE4MwABODUAATg3AAE4OQABODsAATg9AAE4PwABOEEAAThSAAE4VQABOFgAAThbAAE4XgABOGEAAThkAAE4ZwABOGoAAThsAAE4qwABOK0AATivAAE4sQABOLQAATi1AAE4tgABOLcAATi4AAE4ugABOLwAATi9AAE4vgABOMAAATjBAAE5AAABOQIAATkEAAE5BgABOQkAATkKAAE5CwABOQwAATkNAAE5DwABOREAATkSAAE5EwABORUAATkWAAE5VQABOVcAATlaAAE5XAABOV8AATlgAAE5YQABOWIAATljAAE5ZQABOWcAATloAAE5aQABOWsAATlsAAE5eQABOXoAATl7AAE5fQABObwAATm+AAE5wAABOcIAATnFAAE5xgABOccAATnIAAE5yQABOcsAATnNAAE5zgABOc8AATnRAAE50gABOhEAAToTAAE6FQABOhcAAToaAAE6GwABOhwAATodAAE6HgABOiAAAToiAAE6IwABOiQAATomAAE6JwABOmYAATpoAAE6agABOmwAATpvAAE6cAABOnEAATpyAAE6cwABOnUAATp3AAE6eAABOnkAATp7AAE6fAABOrsAATq9AAE6vwABOsEAATrEAAE6xQABOsYAATrHAAE6yAABOsoAATrMAAE6zQABOs4AATrQAAE60QABOxAAATsSAAE7FAABOxYAATsZAAE7GgABOxsAATscAAE7HQABOx8AATshAAE7IgABOyMAATslAAE7JgABO0sAATtvAAE7lgABO7oAATu9AAE7vwABO8EAATvDAAE7xQABO8cAATvIAAE7ywABO9gAATvnAAE76QABO+sAATvtAAE77wABO/EAATvzAAE79QABPAQAATwHAAE8CgABPA0AATwQAAE8EwABPBYAATwZAAE8GwABPFoAATxcAAE8XgABPGAAATxjAAE8ZAABPGUAATxmAAE8ZwABPGkAATxrAAE8bAABPG0AATxvAAE8cAABPK8AATyxAAE8swABPLUAATy4AAE8uQABPLoAATy7AAE8vAABPL4AATzAAAE8wQABPMIAATzEAAE8xQABPQQAAT0GAAE9CAABPQoAAT0NAAE9DgABPQ8AAT0QAAE9EQABPRMAAT0VAAE9FgABPRcAAT0ZAAE9GgABPVkAAT1bAAE9XQABPV8AAT1iAAE9YwABPWQAAT1lAAE9ZgABPWgAAT1qAAE9awABPWwAAT1uAAE9bwABPa4AAT2wAAE9sgABPbQAAT23AAE9uAABPbkAAT26AAE9uwABPb0AAT2/AAE9wAABPcEAAT3DAAE9xAABPgMAAT4FAAE+BwABPgkAAT4MAAE+DQABPg4AAT4PAAE+EAABPhIAAT4UAAE+FQABPhYAAT4YAAE+GQABPlgAAT5aAAE+XAABPl4AAT5hAAE+YgABPmMAAT5kAAE+ZQABPmcAAT5pAAE+agABPmsAAT5tAAE+bgABPrkAAT7cAAE+/AABPxwAAT8eAAE/IAABPyIAAT8kAAE/JwABPygAAT8pAAE/LAABPy0AAT8vAAE/MAABPzIAAT81AAE/NgABPzcAAT86AAE/OwABP0QAAT9RAAE/VgABP1gAAT9aAAE/XwABP2IAAT9lAAE/ZwABP4w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AAVu1AAFbtwABW7kAAVu7AAFbvAABW78AAVvMAAFb2wABW90AAVvfAAFb4QABW+MAAVvlAAFb5wABW+kAAVv4AAFb+wABW/4AAVwBAAFcBAABXAcAAVwKAAFcDQABXA8AAVxOAAFcUAABXFIAAVxUAAFcVwABXFgAAVxZAAFcWgABXFsAAVxdAAFcXwABXGAAAVxhAAFcYwABXGQAAVyjAAFcpQABXKcAAVypAAFcrAABXK0AAVyuAAFcrwABXLAAAVyyAAFctAABXLUAAVy2AAFcuAABXLkAAVz4AAFc+gABXPwAAVz+AAFdAQABXQIAAV0DAAFdBAABXQUAAV0HAAFdCQABXQoAAV0LAAFdDQABXQ4AAV1NAAFdTwABXVEAAV1TAAFdVgABXVcAAV1YAAFdWQABXVoAAV1cAAFdXgABXV8AAV1gAAFdYgABXWMAAV2iAAFdpAABXaYAAV2oAAFdqwABXawAAV2tAAFdrgABXa8AAV2xAAFdswABXbQAAV21AAFdtwABXbgAAV33AAFd+QABXfsAAV39AAFeAAABXgEAAV4CAAFeAwABXgQAAV4GAAFeCAABXgkAAV4KAAFeDAABXg0AAV5MAAFeTgABXlAAAV5SAAFeVQABXlYAAV5XAAFeWAABXlkAAV5bAAFeXQABXl4AAV5fAAFeYQABXmIAAV6tAAFe0AABXvAAAV8QAAFfEgABXxQAAV8WAAFfGAABXxsAAV8cAAFfHQABXyAAAV8hAAFfIwABXyQAAV8mAAFfKAABXykAAV8qAAFfLQABXy4AAV83AAFfRAABX0kAAV9LAAFfTQABX1IAAV9VAAFfWAABX1oAAV9/AAFfowABX8oAAV/uAAFf8QABX/MAAV/1AAFf9wABX/kAAV/7AAFf/AABX/8AAWAMAAFgHQABYB8AAWAhAAFgIwABYCUAAWAnAAFgKQABYCsAAWAtAAFgPgABYEEAAWBEAAFgRwABYEoAAWBNAAFgUAABYFMAAWBWAAFgWAABYJcAAWCZAAFgmwABYJ0AAWCgAAFgoQABYKIAAWCjAAFgpAABYKYAAWCoAAFgqQABYKoAAWCsAAFgrQABYOwAAWDuAAFg8AABYPIAAWD1AAFg9gABYPcAAWD4AAFg+QABYPsAAWD9AAFg/gABYP8AAWEBAAFhAgABYUEAAWFDAAFhRgABYUgAAWFLAAFhTAABYU0AAWFOAAFhTwABYVEAAWFTAAFhVAABYVUAAWFXAAFhWAABYWUAAWFmAAFhZwABYWkAAWGoAAFhqgABYawAAWGuAAFhsQABYbIAAWGzAAFhtAABYbUAAWG3AAFhuQABYboAAWG7AAFhvQABYb4AAWH9AAFh/wABYgEAAWIDAAFiBgABYgcAAWIIAAFiCQABYgoAAWIMAAFiDgABYg8AAWIQAAFiEgABYhMAAWJSAAFiVAABYlYAAWJYAAFiWwABYlwAAWJdAAFiXgABYl8AAWJhAAFiYwABYmQAAWJlAAFiZwABYmgAAWKnAAFiqQABYqsAAWKtAAFisAABYrEAAWKyAAFiswABYrQAAWK2AAFiuAABYrkAAWK6AAFivAABYr0AAWL8AAFi/gABYwAAAWMCAAFjBQABYwYAAWMHAAFjCAABYwkAAWMLAAFjDQABYw4AAWMPAAFjEQABYxIAAWM3AAFjWwABY4IAAWOmAAFjqQABY6sAAWOtAAFjrwABY7EAAWOzAAFjtAABY7cAAWPEAAFj0wABY9UAAWPXAAFj2QABY9sAAWPdAAFj3wABY+EAAWPwAAFj8wABY/YAAWP5AAFj/AABY/8AAWQCAAFkBQABZAcAAWRGAAFkSAABZEoAAWRMAAFkTwABZFAAAWRRAAFkUgABZFMAAWRVAAFkVwABZFgAAWRZAAFkWwABZFwAAWSbAAFknQABZJ8AAWShAAFkpAABZKUAAWSmAAFkpwABZKgAAWSqAAFkrAABZK0AAWSuAAFksAABZLEAAWTwAAFk8gABZPQAAWT2AAFk+QABZPoAAWT7AAFk/AABZP0AAWT/AAFlAQABZQIAAWUDAAFlBQABZQYAAWVFAAFlRwABZUkAAWVLAAFlTgABZU8AAWVQAAFlUQABZVIAAWVUAAFlVgABZVcAAWVYAAFlWgABZVsAAWWaAAFlnAABZZ4AAWWgAAFlowABZaQAAWWlAAFlpgABZacAAWWpAAFlqwABZawAAWWtAAFlrwABZbAAAWXvAAFl8QABZfMAAWX1AAFl+AABZfkAAWX6AAFl+wABZfwAAWX+AAFmAAABZgEAAWYCAAFmBAABZgUAAWZEAAFmRgABZkgAAWZKAAFmTQABZk4AAWZPAAFmUAABZlEAAWZTAAFmVQABZlYAAWZXAAFmWQABZloAAWalAAFmyAABZugAAWcIAAFnCgABZwwAAWcOAAFnEAABZxMAAWcUAAFnFQABZxgAAWcZAAFnGwABZxwAAWceAAFnIQABZyIAAWcjAAFnJgABZycAAWcsAAFnOQABZz4AAWdAAAFnQgABZ0cAAWdKAAFnTQABZ08AAWd0AAFnmAABZ78AAWfjAAFn5gABZ+gAAWfqAAFn7AABZ+4AAWfwAAFn8QABZ/QAAWgBAAFoEgABaBQAAWgWAAFoGAABaBoAAWgcAAFoHgABaCAAAWgiAAFoMwABaDYAAWg5AAFoPAABaD8AAWhCAAFoRQABaEgAAWhLAAFoTQABaIwAAWiOAAFokAABaJIAAWiVAAFolgABaJcAAWiYAAFomQABaJsAAWidAAFongABaJ8AAWihAAFoogABaOEAAWjjAAFo5QABaOcAAWjqAAFo6wABaOwAAWjtAAFo7gABaPAAAWjyAAFo8wABaPQAAWj2AAFo9wABaTYAAWk4AAFpOwABaT0AAWlAAAFpQQABaUIAAWlDAAFpRAABaUYAAWlIAAFpSQABaUoAAWlMAAFpTQABaVoAAWlbAAFpXAABaV4AAWmdAAFpnwABaaEAAWmjAAFppgABaacAAWmoAAFpqQABaaoAAWmsAAFprgABaa8AAWmwAAFpsgABabMAAWnyAAFp9AABafYAAWn4AAFp+wABafwAAWn9AAFp/gABaf8AAWoBAAFqAwABagQAAWoFAAFqBwABaggAAWpHAAFqSQABaksAAWpNAAFqUAABalEAAWpSAAFqUwABalQAAWpWAAFqWAABalkAAWpaAAFqXAABal0AAWqcAAFqngABaqEAAWqjAAFqpgABaqcAAWqoAAFqqQABaqoAAWqsAAFqrgABaq8AAWqwAAFqsgABarMAAWrGAAFrBQABawcAAWsJAAFrCwABaw4AAWsPAAFrEAABaxEAAWsSAAFrFAABaxYAAWsXAAFrGAABaxoAAWsbAAFrQAABa2QAAWuLAAFrrwABa7IAAWu0AAFrtgABa7gAAWu6AAFrvAABa70AAWvAAAFrzQABa9wAAWveAAFr4AABa+IAAWvkAAFr5gABa+gAAWvqAAFr+QABa/wAAWv/AAFsAgABbAUAAWwIAAFsCwABbA4AAWwQAAFsTwABbFEAAWxTAAFsVQABbFgAAWxZAAFsWgABbFsAAWxcAAFsXgABbGAAAWxhAAFsYgABbGQAAWxlAAFspAABbKYAAWyoAAFsqgABbK0AAWyuAAFsrwABbLAAAWyxAAFsswABbLUAAWy2AAFstwABbLkAAWy6AAFs+QABbPsAAWz9AAFs/wABbQIAAW0DAAFtBAABbQUAAW0GAAFtCAABbQoAAW0LAAFtDAABbQ4AAW0PAAFtTgABbVAAAW1SAAFtVAABbVcAAW1YAAFtWQABbVoAAW1bAAFtXQABbV8AAW1gAAFtYQABbWMAAW1kAAFtowABbaUAAW2nAAFtqQABbawAAW2tAAFtrgABba8AAW2wAAFtsgABbbQAAW21AAFttgABbbgAAW25AAFt+AABbfoAAW38AAFt/gABbgEAAW4CAAFuAwABbgQAAW4FAAFuBwABbgkAAW4KAAFuCwABbg0AAW4OAAFuTQABbk8AAW5RAAFuUwABblYAAW5XAAFuWAABblkAAW5aAAFuXAABbl4AAW5fAAFuYAABbmIAAW5jAAFurgABbtEAAW7xAAFvEQABbxMAAW8VAAFvFwABbxkAAW8cAAFvHQABbx4AAW8hAAFvIgABbyQAAW8lAAFvJwABbyoAAW8rAAFvLAABby8AAW8wAAFvNQABb0IAAW9HAAFvSQABb0sAAW9QAAFvUwABb1YAAW9YAAFvfQABb6EAAW/IAAFv7AABb+8AAW/xAAFv8wABb/UAAW/3AAFv+QABb/oAAW/9AAFwCgABcBsAAXAdAAFwHwABcCEAAXAjAAFwJQABcCcAAXApAAFwKwABcDwAAXA/AAFwQgABcEUAAXBIAAFwSwABcE4AAXBRAAFwVAABcFYAAXCVAAFwlwABcJkAAXCbAAFwngABcJ8AAXCgAAFwoQABcKIAAXCkAAFwpgABcKcAAXCoAAFwqgABcKsAAXDqAAFw7AABcO4AAXDwAAFw8wABcPQAAXD1AAFw9gABcPcAAXD5AAFw+wABcPwAAXD9AAFw/wABcQAAAXE/AAFxQQABcUQAAXFGAAFxSQABcUoAAXFLAAFxTAABcU0AAXFPAAFxUQABcVIAAXFTAAFxVQABcVYAAXFjAAFxZAABcWUAAXFnAAFxpgABcagAAXGqAAFxrAABca8AAXGwAAFxsQABcbIAAXGzAAFxtQABcbcAAXG4AAFxuQABcbsAAXG8AAFx+wABcf0AAXH/AAFyAQABcgQAAXIFAAFyBgABcgcAAXIIAAFyCgABcgwAAXINAAFyDgABchAAAXIRAAFyUAABclIAAXJUAAFyVgABclkAAXJaAAFyWwABclwAAXJdAAFyXwABcmEAAXJiAAFyYwABcmUAAXJmAAFypQABcqcAAXKpAAFyqwABcq4AAXKvAAFysAABcrEAAXKyAAFytAABcrYAAXK3AAFyuAABcroAAXK7AAFy+gABcvwAAXL+AAFzAAABcwMAAXMEAAFzBQABcwYAAXMHAAFzCQABcwsAAXMMAAFzDQABcw8AAXMQAAFzNQABc1kAAXOAAAFzpAABc6cAAXOpAAFzqwABc60AAXOvAAFzsQABc7IAAXO1AAFzwgABc9EAAXPTAAFz1QABc9cAAXPZAAFz2wABc90AAXPfAAFz7gABc/EAAXP0AAFz9wABc/oAAXP9AAF0AAABdAMAAXQFAAF0RAABdEYAAXRIAAF0SgABdE0AAXROAAF0TwABdFAAAXRRAAF0UwABdFUAAXRWAAF0VwABdFkAAXRaAAF0mQABdJsAAXSdAAF0nwABdKIAAXSjAAF0pAABdKUAAXSmAAF0qAABdKoAAXSrAAF0rAABdK4AAXSvAAF07gABdPAAAXTyAAF09AABdPcAAXT4AAF0+QABdPoAAXT7AAF0/QABdP8AAXUAAAF1AQABdQMAAXUEAAF1QwABdUUAAXVHAAF1SQABdUwAAXVNAAF1TgABdU8AAXVQAAF1UgABdVQAAXVVAAF1VgABdVgAAXVZAAF1mAABdZoAAXWcAAF1ngABdaEAAXWiAAF1owABdaQAAXWlAAF1pwABdakAAXWqAAF1qwABda0AAXWuAAF17QABde8AAXXxAAF18wABdfYAAXX3AAF1+AABdfkAAXX6AAF1/AABdf4AAXX/AAF2AAABdgIAAXYDAAF2QgABdkQAAXZGAAF2SAABdksAAXZMAAF2TQABdk4AAXZPAAF2UQABdlMAAXZUAAF2VQABdlcAAXZYAAF2owABdsYAAXbmAAF3BgABdwgAAXcKAAF3DAABdw4AAXcRAAF3EgABdxMAAXcWAAF3FwABdxkAAXcaAAF3HAABdx4AAXcfAAF3IAABdyMAAXckAAF3KQABdzYAAXc7AAF3PQABdz8AAXdEAAF3RwABd0oAAXdMAAF3cQABd5UAAXe8AAF34AABd+MAAXflAAF35wABd+kAAXfrAAF37QABd+4AAXfxAAF3/gABeA8AAXgRAAF4EwABeBUAAXgXAAF4GQABeBsAAXgdAAF4HwABeDAAAXgzAAF4NgABeDkAAXg8AAF4PwABeEIAAXhFAAF4SAABeEoAAXiJAAF4iwABeI0AAXiPAAF4kgABeJMAAXiUAAF4lQABeJYAAXiYAAF4mgABeJsAAXicAAF4ngABeJ8AAXjeAAF44AABeOIAAXjkAAF45wABeOgAAXjpAAF46gABeOsAAXjtAAF47wABePAAAXjxAAF48wABePQAAXkzAAF5NQABeTgAAXk6AAF5PQABeT4AAXk/AAF5QAABeUEAAXlDAAF5RQABeUYAAXlHAAF5SQABeUoAAXlXAAF5WAABeVkAAXlbAAF5mgABeZwAAXmeAAF5oAABeaMAAXmkAAF5pQABeaYAAXmnAAF5qQABeasAAXmsAAF5rQABea8AAXmwAAF57wABefEAAXnzAAF59QABefgAAXn5AAF5+gABefsAAXn8AAF5/gABegAAAXoBAAF6AgABegQAAXoFAAF6RAABekYAAXpIAAF6SgABek0AAXpOAAF6TwABelAAAXpRAAF6UwABelUAAXpWAAF6VwABelkAAXpaAAF6mQABepsAAXqdAAF6nwABeqIAAXqjAAF6pAABeqUAAXqmAAF6qAABeqoAAXqrAAF6rAABeq4AAXqvAAF67gABevAAAXryAAF69AABevcAAXr4AAF6+QABevoAAXr7AAF6/QABev8AAXsAAAF7AQABewMAAXsEAAF7KQABe00AAXt0AAF7mAABe5sAAXudAAF7nwABe6EAAXujAAF7pQABe6YAAXupAAF7tgABe8UAAXvHAAF7yQABe8sAAXvNAAF7zwABe9EAAXvTAAF74gABe+UAAXvoAAF76wABe+4AAXvxAAF79AABe/cAAXv5AAF8OAABfDoAAXw8AAF8PgABfEEAAXxCAAF8QwABfEQAAXxFAAF8RwABfEkAAXxKAAF8SwABfE0AAXxOAAF8jQABfI8AAXyRAAF8kwABfJYAAXyXAAF8mAABfJkAAXyaAAF8nAABfJ4AAXyfAAF8oAABfKIAAXyjAAF84gABfOQAAXzmAAF86AABfOsAAXzsAAF87QABfO4AAXzvAAF88QABfPMAAXz0AAF89QABfPcAAXz4AAF9NwABfTkAAX08AAF9PgABfUEAAX1CAAF9QwABfUQAAX1FAAF9RwABfUkAAX1KAAF9SwABfU0AAX1OAAF9jQABfY8AAX2RAAF9kwABfZYAAX2XAAF9mAABfZkAAX2aAAF9nAABfZ4AAX2fAAF9oAABfaIAAX2jAAF94gABfeQAAX3mAAF96AABfesAAX3sAAF97QABfe4AAX3vAAF98QABffMAAX30AAF99QABffcAAX34AAF+NwABfjkAAX48AAF+PgABfkEAAX5CAAF+QwABfkQAAX5FAAF+RwABfkkAAX5KAAF+SwABfk0AAX5OAAF+mQABfrwAAX7cAAF+/AABfv4AAX8AAAF/AgABfwQAAX8HAAF/CAABfwkAAX8MAAF/DQABfw8AAX8QAAF/EgABfxUAAX8WAAF/FwABfxoAAX8bAAF/IAABfy0AAX8yAAF/NAABfzYAAX87AAF/PgABf0EAAX9DAAF/aAABf4wAAX+zAAF/1wABf9oAAX/cAAF/3gABf+AAAX/iAAF/5AABf+UAAX/oAAF/9QABgAYAAYAIAAGACgABgAwAAYAOAAGAEAABgBIAAYAUAAGAFgABgCcAAYAqAAGALQABgDAAAYAzAAGANgABgDkAAYA8AAGAPwABgEEAAYCAAAGAggABgIQAAYCGAAGAiQABgIoAAYCLAAGAjAABgI0AAYCPAAGAkQABgJIAAYCTAAGAlQABgJYAAYDVAAGA1wABgNkAAYDbAAGA3gABgN8AAYDgAAGA4QABgOIAAYDkAAGA5gABgOcAAYDoAAGA6gABgOsAAYEqAAGBLAABgS8AAYExAAGBNAABgTUAAYE2AAGBNwABgTgAAYE6AAGBPAABgT0AAYE+AAGBQAABgUEAAYFOAAGBTwABgVAAAYFSAAGBkQABgZMAAYGVAAGBlwABgZoAAYGbAAGBnAABgZ0AAYGeAAGBoAABgaIAAYGjAAGBpAABgaYAAYGnAAGB5gABgegAAYHqAAGB7AABge8AAYHwAAGB8QABgfIAAYHzAAGB9QABgfcAAYH4AAGB+QABgfsAAYH8AAGCOwABgj0AAYI/AAGCQQABgkQAAYJFAAGCRgABgkcAAYJIAAGCSgABgkwAAYJNAAGCTgABglAAAYJRAAGCkAABgpIAAYKUAAGClgABgpkAAYKaAAGCmwABgpwAAYKdAAGCnwABgqEAAYKiAAGCowABgqUAAYKmAAGC5QABgucAAYLpAAGC6wABgu4AAYLvAAGC8AABgvEAAYLyAAGC9AABgvYAAYL3AAGC+AABgvoAAYL7AAGDIAABg0QAAYNrAAGDjwABg5IAAYOUAAGDlgABg5gAAYOaAAGDnAABg50AAYOgAAGDrQABg7wAAYO+AAGDwAABg8IAAYPEAAGDxgABg8gAAYPKAAGD2QABg9wAAYPfAAGD4gABg+UAAYPoAAGD6wABg+4AAYPwAAGELwABhDEAAYQzAAGENQABhDgAAYQ5AAGEOgABhDsAAYQ8AAGEPgABhEAAAYRBAAGEQgABhEQAAYRFAAGEhAABhIYAAYSIAAGEigABhI0AAYSOAAGEjwABhJAAAYSRAAGEkwABhJUAAYSWAAGElwABhJkAAYSaAAGE2QABhNsAAYTdAAGE3wABhOIAAYTjAAGE5AABhOUAAYTmAAGE6AABhOoAAYTrAAGE7AABhO4AAYTvAAGFLgABhTAAAYUyAAGFNAABhTcAAYU4AAGFOQABhToAAYU7AAGFPQABhT8AAYVAAAGFQQABhUMAAYVEAAGFgwABhYUAAYWHAAGFiQABhYwAAYWNAAGFjgABhY8AAYWQAAGFkgABhZQAAYWVAAGFlgABhZgAAYWZAAGF2AABhdoAAYXcAAGF3gABheEAAYXiAAGF4wABheQAAYXlAAGF5wABhekAAYXqAAGF6wABhe0AAYXuAAGGLQABhi8AAYYxAAGGMwABhjYAAYY3AAGGOAABhjkAAYY6AAGGPAABhj4AAYY/AAGGQAABhkIAAYZDAAGGjgABhrEAAYbRAAGG8QABhvMAAYb1AAGG9wABhvkAAYb8AAGG/QABhv4AAYcBAAGHAgABhwQAAYcFAAGHBwABhwoAAYcLAAGHDAABhw8AAYcQAAGHFQABhyIAAYcnAAGHKQABhysAAYcwAAGHMwABhzYAAYc4AAGHXQABh4EAAYeoAAGHzAABh88AAYfRAAGH0wABh9UAAYfXAAGH2QABh9oAAYfdAAGH6gABh/sAAYf9AAGH/wABiAEAAYgDAAGIBQABiAcAAYgJAAGICwABiBwAAYgfAAGIIgABiCUAAYgoAAGIKwABiC4AAYgxAAGINAABiDYAAYh1AAGIdwABiHkAAYh7AAGIfgABiH8AAYiAAAGIgQABiIIAAYiEAAGIhgABiIcAAYiIAAGIigABiIsAAYjKAAGIzAABiM4AAYjQAAGI0wABiNQAAYjVAAGI1gABiNcAAYjZAAGI2wABiNwAAYjdAAGI3wABiOAAAYkfAAGJIQABiSQAAYkmAAGJKQABiSoAAYkrAAGJLAABiS0AAYkvAAGJMQABiTIAAYkzAAGJNQABiTYAAYlDAAGJRAABiUUAAYlHAAGJhgABiYgAAYmKAAGJjAABiY8AAYmQAAGJkQABiZIAAYmTAAGJlQABiZcAAYmYAAGJmQABiZsAAYmcAAGJ2wABid0AAYnfAAGJ4QABieQAAYnlAAGJ5gABiecAAYnoAAGJ6gABiewAAYntAAGJ7gABifAAAYnxAAGKMAABijIAAYo0AAGKNgABijkAAYo6AAGKOwABijwAAYo9AAGKPwABikEAAYpCAAGKQwABikUAAYpGAAGKhQABiocAAYqKAAGKjAABio8AAYqQAAGKkQABipIAAYqTAAGKlQABipcAAYqYAAGKmQABipsAAYqcAAGKsAABiu8AAYrxAAGK8wABivUAAYr4AAGK+QABivoAAYr7AAGK/AABiv4AAYsAAAGLAQABiwIAAYsEAAGLBQABiyoAAYtOAAGLdQABi5kAAYucAAGLngABi6AAAYuiAAGLpAABi6YAAYunAAGLqgABi7cAAYvGAAGLyAABi8oAAYvMAAGLzgABi9AAAYvSAAGL1AABi+MAAYvmAAGL6QABi+wAAYvvAAGL8gABi/UAAYv4AAGL+gABjDkAAYw7AAGMPQABjD8AAYxCAAGMQwABjEQAAYxFAAGMRgABjEgAAYxKAAGMSwABjEwAAYxOAAGMTwABjI4AAYyQAAGMkgABjJQAAYyXAAGMmAABjJkAAYyaAAGMmwABjJ0AAYyfAAGMoAABjKEAAYyjAAGMpAABjOMAAYzlAAGM5wABjOkAAYzsAAGM7QABjO4AAYzvAAGM8AABjPIAAYz0AAGM9QABjPYAAYz4AAGM+QABjTgAAY06AAGNPAABjT4AAY1BAAGNQgABjUMAAY1EAAGNRQABjUcAAY1JAAGNSgABjUsAAY1NAAGNTgABjY0AAY2PAAGNkQABjZMAAY2WAAGNlwABjZgAAY2ZAAGNmgABjZwAAY2eAAGNnwABjaAAAY2iAAGNowABjeIAAY3kAAGN5gABjegAAY3rAAGN7AABje0AAY3uAAGN7wABjfEAAY3zAAGN9AABjfUAAY33AAGN+AABjjcAAY45AAGOOwABjj0AAY5AAAGOQQABjkIAAY5DAAGORAABjkYAAY5IAAGOSQABjkoAAY5MAAGOTQABjpgAAY67AAGO2wABjvsAAY79AAGO/wABjwEAAY8DAAGPBgABjwcAAY8IAAGPCwABjwwAAY8OAAGPDwABjxEAAY8UAAGPFQABjxYAAY8ZAAGPGgABjx8AAY8sAAGPMQABjzMAAY81AAGPOgABjz0AAY9AAAGPQgABj2cAAY+LAAGPsgABj9YAAY/ZAAGP2wABj90AAY/fAAGP4QABj+MAAY/kAAGP5wABj/QAAZAFAAGQBwABkAkAAZALAAGQDQABkA8AAZARAAGQEwABkBUAAZAmAAGQKQABkCwAAZAvAAGQMgABkDUAAZA4AAGQOwABkD4AAZBAAAGQfwABkIEAAZCDAAGQhQABkIgAAZCJAAGQigABkIsAAZCMAAGQjgABkJAAAZCRAAGQkgABkJQAAZCVAAGQ1AABkNYAAZDYAAGQ2gABkN0AAZDeAAGQ3wABkOAAAZDhAAGQ4wABkOUAAZDmAAGQ5wABkOkAAZDqAAGRKQABkSsAAZEuAAGRMAABkTMAAZE0AAGRNQABkTYAAZE3AAGROQABkTsAAZE8AAGRPQABkT8AAZFAAAGRTQABkU4AAZFPAAGRUQABkZAAAZGSAAGRlAABkZYAAZGZAAGRmgABkZsAAZGcAAGRnQABkZ8AAZGhAAGRogABkaMAAZGlAAGRpgABkeUAAZHnAAGR6QABkesAAZHuAAGR7wABkfAAAZHxAAGR8gABkfQAAZH2AAGR9wABkfgAAZH6AAGR+wABkjoAAZI8AAGSPgABkkAAAZJDAAGSRAABkkUAAZJGAAGSRwABkkkAAZJLAAGSTAABkk0AAZJPAAGSUAABko8AAZKRAAGSlAABkpYAAZKZAAGSmgABkpsAAZKcAAGSnQABkp8AAZKhAAGSogABkqMAAZKlAAGSpgABkrUAAZL0AAGS9gABkvgAAZL6AAGS/QABkv4AAZL/AAGTAAABkwEAAZMDAAGTBQABkwYAAZMHAAGTCQABkwoAAZMvAAGTUwABk3oAAZOeAAGToQABk6MAAZOlAAGTpwABk6kAAZOrAAGTrAABk68AAZO8AAGTywABk80AAZPPAAGT0QABk9MAAZPVAAGT1wABk9kAAZPoAAGT6wABk+4AAZPxAAGT9AABk/cAAZP6AAGT/QABk/8AAZQ+AAGUQAABlEIAAZREAAGURwABlEgAAZRJAAGUSgABlEsAAZRNAAGUTwABlFAAAZRRAAGUUwABlFQAAZSTAAGUlQABlJcAAZSZAAGUnAABlJ0AAZSeAAGUnwABlKAAAZSiAAGUpAABlKUAAZSmAAGUqAABlKkAAZToAAGU6gABlOwAAZTuAAGU8QABlPIAAZTzAAGU9AABlPUAAZT3AAGU+QABlPoAAZT7AAGU/QABlP4AAZU9AAGVPwABlUEAAZVDAAGVRgABlUcAAZVIAAGVSQABlUoAAZVMAAGVTgABlU8AAZVQAAGVUgABlVMAAZWSAAGVlAABlZYAAZWYAAGVmwABlZwAAZWdAAGVngABlZ8AAZWhAAGVowABlaQAAZWlAAGVpwABlagAAZXnAAGV6QABlesAAZXtAAGV8AABlfEAAZXyAAGV8wABlfQAAZX2AAGV+AABlfkAAZX6AAGV/AABlf0AAZY8AAGWPgABlkAAAZZCAAGWRQABlkYAAZZHAAGWSAABlkkAAZZLAAGWTQABlk4AAZZPAAGWUQABllIAAZadAAGWwAABluAAAZcAAAGXAgABlwQAAZcGAAGXCAABlwsAAZcMAAGXDQABlxAAAZcRAAGXEwABlxQAAZcWAAGXGQABlxoAAZcbAAGXHgABlx8AAZckAAGXMQABlzYAAZc4AAGXOgABlz8AAZdCAAGXRQABl0cAAZdsAAGXkAABl7cAAZfbAAGX3gABl+AAAZfiAAGX5AABl+YAAZfoAAGX6QABl+wAAZf5AAGYCgABmAwAAZgOAAGYEAABmBIAAZgUAAGYFgABmBgAAZgaAAGYKwABmC4AAZgxAAGYNAABmDcAAZg6AAGYPQABmEAAAZhDAAGYRQABmIQAAZiGAAGYiAABmIoAAZiNAAGYjgABmI8AAZiQAAGYkQABmJMAAZiVAAGYlgABmJcAAZiZAAGYmgABmNkAAZjbAAGY3QABmN8AAZjiAAGY4wABmOQAAZjlAAGY5gABmOgAAZjqAAGY6wABmOwAAZjuAAGY7wABmS4AAZkwAAGZMwABmTUAAZk4AAGZOQABmToAAZk7AAGZPAABmT4AAZlAAAGZQQABmUIAAZlEAAGZRQABmVIAAZlTAAGZVAABmVYAAZmVAAGZlwABmZkAAZmbAAGZngABmZ8AAZmgAAGZoQABmaIAAZmkAAGZpgABmacAAZmoAAGZqgABmasAAZnqAAGZ7AABme4AAZnwAAGZ8wABmfQAAZn1AAGZ9gABmfcAAZn5AAGZ+wABmfwAAZn9AAGZ/wABmgAAAZo/AAGaQQABmkMAAZpFAAGaSAABmkkAAZpKAAGaSwABmkwAAZpOAAGaUAABmlEAAZpSAAGaVAABmlUAAZqUAAGalgABmpkAAZqbAAGangABmp8AAZqgAAGaoQABmqIAAZqkAAGapgABmqcAAZqoAAGaqgABmqsAAZq/AAGa/gABmwAAAZsCAAGbBAABmwcAAZsIAAGbCQABmwoAAZsLAAGbDQABmw8AAZsQAAGbEQABmxMAAZsUAAGbOQABm10AAZuEAAGbqAABm6sAAZutAAGbrwABm7EAAZuzAAGbtQABm7YAAZu5AAGbxgABm9UAAZvXAAGb2QABm9sAAZvdAAGb3wABm+EAAZvjAAGb8gABm/UAAZv4AAGb+wABm/4AAZwBAAGcBAABnAcAAZwJAAGcSAABnEoAAZxMAAGcTgABnFEAAZxSAAGcUwABnFQAAZxVAAGcVwABnFkAAZxaAAGcWwABnF0AAZxeAAGcnQABnJ8AAZyhAAGcowABnKYAAZynAAGcqAABnKkAAZyqAAGcrAABnK4AAZyvAAGcsAABnLIAAZyzAAGc8gABnPQAAZz2AAGc+AABnPsAAZz8AAGc/QABnP4AAZz/AAGdAQABnQMAAZ0EAAGdBQABnQcAAZ0IAAGdRwABnUkAAZ1LAAGdTQABnVAAAZ1RAAGdUgABnVMAAZ1UAAGdVgABnVgAAZ1ZAAGdWgABnVwAAZ1dAAGdnAABnZ4AAZ2gAAGdogABnaUAAZ2mAAGdpwABnagAAZ2pAAGdqwABna0AAZ2uAAGdrwABnbEAAZ2yAAGd8QABnfMAAZ31AAGd9wABnfoAAZ37AAGd/AABnf0AAZ3+AAGeAAABngIAAZ4DAAGeBAABngYAAZ4HAAGeRgABnkgAAZ5KAAGeTAABnk8AAZ5QAAGeUQABnlIAAZ5TAAGeVQABnlcAAZ5YAAGeWQABnlsAAZ5cAAGepwABnsoAAZ7qAAGfCgABnwwAAZ8OAAGfEAABnxIAAZ8VAAGfFgABnxcAAZ8aAAGfGwABnx0AAZ8eAAGfIAABnyMAAZ8kAAGfJQABnygAAZ8pAAGfMgABnz8AAZ9EAAGfRgABn0gAAZ9NAAGfUAABn1MAAZ9VAAGfegABn54AAZ/FAAGf6QABn+wAAZ/uAAGf8AABn/IAAZ/0AAGf9gABn/cAAZ/6AAGgBwABoBgAAaAaAAGgHAABoB4AAaAgAAGgIgABoCQAAaAmAAGgKAABoDkAAaA8AAGgPwABoEIAAaBFAAGgSAABoEsAAaBOAAGgUQABoFMAAaCSAAGglAABoJYAAaCYAAGgmwABoJwAAaCdAAGgngABoJ8AAaChAAGgowABoKQAAaClAAGgpwABoKgAAaDnAAGg6QABoOsAAaDtAAGg8AABoPEAAaDyAAGg8wABoPQAAaD2AAGg+AABoPkAAaD6AAGg/AABoP0AAaE8AAGhPgABoUEAAaFDAAGhRgABoUcAAaFIAAGhSQABoUoAAaFMAAGhTgABoU8AAaFQAAGhUgABoVMAAaFgAAGhYQABoWIAAaFkAAGhowABoaUAAaGnAAGhqQABoawAAaGtAAGhrgABoa8AAaGwAAGhsgABobQAAaG1AAGhtgABobgAAaG5AAGh+AABofoAAaH8AAGh/gABogEAAaICAAGiAwABogQAAaIFAAGiBwABogkAAaIKAAGiCwABog0AAaIOAAGiTQABok8AAaJRAAGiUwABolYAAaJXAAGiWAABolkAAaJaAAGiXAABol4AAaJfAAGiYAABomIAAaJjAAGiogABoqQAAaKmAAGiqAABoqsAAaKsAAGirQABoq4AAaKvAAGisQABorMAAaK0AAGitQABorcAAaK4AAGi9wABovkAAaL7AAGi/QABowAAAaMBAAGjAgABowMAAaMEAAGjBgABowgAAaMJAAGjCgABowwAAaMNAAGjMgABo1YAAaN9AAGjoQABo6QAAaOmAAGjqAABo6oAAaOsAAGjrgABo68AAaOyAAGjvwABo84AAaPQAAGj0gABo9QAAaPWAAGj2AABo9oAAaPcAAGj6wABo+4AAaPxAAGj9AABo/cAAaP6AAGj/QABpAAAAaQCAAGkQQABpEMAAaRFAAGkRwABpEoAAaRLAAGkTAABpE0AAaROAAGkUAABpFIAAaRTAAGkVAABpFYAAaRXAAGklgABpJgAAaSaAAGknAABpJ8AAaSgAAGkoQABpKIAAaSjAAGkpQABpKcAAaSoAAGkqQABpKsAAaSsAAGk6wABpO0AAaTvAAGk8QABpPQAAaT1AAGk9gABpPcAAaT4AAGk+gABpPwAAaT9AAGk/gABpQAAAaUBAAGlQAABpUIAAaVEAAGlRgABpUkAAaVKAAGlSwABpUwAAaVNAAGlTwABpVEAAaVSAAGlUwABpVUAAaVWAAGllQABpZcAAaWZAAGlmwABpZ4AAaWfAAGloAABpaEAAaWiAAGlpAABpaYAAaWnAAGlqAABpaoAAaWrAAGl6gABpewAAaXuAAGl8AABpfMAAaX0AAGl9QABpfYAAaX3AAGl+QABpfsAAaX8AAGl/QABpf8AAaYAAAGmPwABpkEAAaZDAAGmRQABpkgAAaZJAAGmSgABpksAAaZMAAGmTgABplAAAaZRAAGmUgABplQAAaZVAAGmoAABpsMAAabjAAGnAwABpwUAAacHAAGnCQABpwsAAacOAAGnDwABpxAAAacTAAGnFAABpxYAAacXAAGnGQABpxwAAacdAAGnHgABpyEAAaciAAGnJwABpzQAAac5AAGnOwABpz0AAadCAAGnRQABp0gAAadKAAGnbwABp5MAAae6AAGn3gABp+EAAafjAAGn5QABp+cAAafpAAGn6wABp+wAAafvAAGn/AABqA0AAagPAAGoEQABqBMAAagVAAGoFwABqBkAAagbAAGoHQABqC4AAagxAAGoNAABqDcAAag6AAGoPQABqEAAAahDAAGoRgABqEgAAaiHAAGoiQABqIsAAaiNAAGokAABqJEAAaiSAAGokwABqJQAAaiWAAGomAABqJkAAaiaAAGonAABqJ0AAajcAAGo3gABqOAAAajiAAGo5QABqOYAAajnAAGo6AABqOkAAajrAAGo7QABqO4AAajvAAGo8QABqPIAAakxAAGpMwABqTYAAak4AAGpOwABqTwAAak9AAGpPgABqT8AAalBAAGpQwABqUQAAalFAAGpRwABqUgAAalVAAGpVgABqVcAAalZAAGpmAABqZoAAamcAAGpngABqaEAAamiAAGpowABqaQAAamlAAGppwABqakAAamqAAGpqwABqa0AAamuAAGp7QABqe8AAanxAAGp8wABqfYAAan3AAGp+AABqfkAAan6AAGp/AABqf4AAan/AAGqAAABqgIAAaoDAAGqQgABqkQAAapGAAGqSAABqksAAapMAAGqTQABqk4AAapPAAGqUQABqlMAAapUAAGqVQABqlcAAapYAAGqlwABqpkAAaqbAAGqnQABqqAAAaqhAAGqogABqqMAAaqkAAGqpgABqqgAAaqpAAGqqgABqqwAAaqtAAGq7AABqu4AAarwAAGq8gABqvUAAar2AAGq9wABqvgAAar5AAGq+wABqv0AAar+AAGq/wABqwEAAasCAAGrJwABq0sAAatyAAGrlgABq5kAAaubAAGrnQABq58AAauhAAGrowABq6QAAaunAAGrtAABq8MAAavFAAGrxwABq8kAAavLAAGrzQABq88AAavRAAGr4AABq+MAAavmAAGr6QABq+wAAavvAAGr8gABq/UAAav3AAGsNgABrDgAAaw6AAGsPAABrD8AAaxAAAGsQQABrEIAAaxDAAGsRQABrEcAAaxIAAGsSQABrEsAAaxMAAGsiwABrI0AAayPAAGskQABrJQAAayVAAGslgABrJcAAayYAAGsmgABrJwAAaydAAGsngABrKAAAayhAAGs4AABrOIAAazkAAGs5gABrOkAAazqAAGs6wABrOwAAaztAAGs7wABrPEAAazyAAGs8wABrPUAAaz2AAGtNQABrTcAAa05AAGtOwABrT4AAa0/AAGtQAABrUEAAa1CAAGtRAABrUYAAa1HAAGtSAABrUoAAa1LAAGtigABrYwAAa2OAAGtkAABrZMAAa2UAAGtlQABrZYAAa2XAAGtmQABrZsAAa2cAAGtnQABrZ8AAa2gAAGt3wABreEAAa3jAAGt5QABregAAa3pAAGt6gABresAAa3sAAGt7gABrfAAAa3xAAGt8gABrfQAAa31AAGuNAABrjYAAa44AAGuOgABrj0AAa4+AAGuPwABrkAAAa5BAAGuQwABrkUAAa5GAAGuRwABrkkAAa5KAAGulQABrrgAAa7YAAGu+AABrvoAAa78AAGu/gABrwAAAa8DAAGvBAABrwUAAa8IAAGvCQABrwsAAa8MAAGvDgABrxEAAa8SAAGvEwABrxYAAa8XAAGvHAABrykAAa8uAAGvMAABrzIAAa83AAGvOgABrz0AAa8/AAGvZAABr4gAAa+vAAGv0wABr9YAAa/YAAGv2gABr9wAAa/eAAGv4AABr+EAAa/kAAGv8QABsAIAAbAEAAGwBgABsAgAAbAKAAGwDAABsA4AAbAQAAGwEgABsCMAAbAmAAGwKQABsCwAAbAvAAGwMgABsDUAAbA4AAGwOwABsD0AAbB8AAGwfgABsIAAAbCCAAGwhQABsIYAAbCHAAGwiAABsIkAAbCLAAGwjQABsI4AAbCPAAGwkQABsJIAAbDRAAGw0wABsNUAAbDXAAGw2gABsNsAAbDcAAGw3QABsN4AAbDgAAGw4gABsOMAAbDkAAGw5gABsOcAAbEmAAGxKAABsSsAAbEtAAGxMAABsTEAAbEyAAGxMwABsTQAAbE2AAGxOAABsTkAAbE6AAGxPAABsT0AAbFKAAGxSwABsUwAAbFOAAGxjQABsY8AAbGRAAGxkwABsZYAAbGXAAGxmAABsZkAAbGaAAGxnAABsZ4AAbGfAAGxoAABsaIAAbGjAAGx4gABseQAAbHmAAGx6AABsesAAbHsAAGx7QABse4AAbHvAAGx8QABsfMAAbH0AAGx9QABsfcAAbH4AAGyNwABsjkAAbI7AAGyPQABskAAAbJBAAGyQgABskMAAbJEAAGyRgABskgAAbJJAAGySgABskwAAbJNAAGyjAABso4AAbKQAAGykgABspUAAbKWAAGylwABspgAAbKZAAGymwABsp0AAbKeAAGynwABsqEAAbKiAAGy4QABsuMAAbLlAAGy5wABsuoAAbLrAAGy7AABsu0AAbLuAAGy8AABsvIAAbLzAAGy9AABsvYAAbL3AAGzHAABs0AAAbNnAAGziwABs44AAbOQAAGzkgABs5QAAbOWAAGzmAABs5kAAbOcAAGzqQABs7gAAbO6AAGzvAABs74AAbPAAAGzwgABs8QAAbPGAAGz1QABs9gAAbPbAAGz3gABs+EAAbPkAAGz5wABs+oAAbPsAAG0KwABtC0AAbQvAAG0MQABtDQAAbQ1AAG0NgABtDcAAbQ4AAG0OgABtDwAAbQ9AAG0PgABtEAAAbRBAAG0gAABtIIAAbSEAAG0hgABtIkAAbSKAAG0iwABtIwAAbSNAAG0jwABtJEAAbSSAAG0kwABtJUAAbSWAAG01QABtNcAAbTZAAG02wABtN4AAbTfAAG04AABtOEAAbTiAAG05AABtOYAAbTnAAG06AABtOoAAbTrAAG1KgABtSwAAbUvAAG1MQABtTQAAbU1AAG1NgABtTcAAbU4AAG1OgABtTwAAbU9AAG1PgABtUAAAbVBAAG1gAABtYIAAbWEAAG1hgABtYkAAbWKAAG1iwABtYwAAbWNAAG1jwABtZEAAbWSAAG1kwABtZUAAbWWAAG11QABtdcAAbXZAAG12wABtd4AAbXfAAG14AABteEAAbXiAAG15AABteYAAbXnAAG16AABteoAAbXrAAG2KgABtiwAAbYvAAG2MQABtjQAAbY1AAG2NgABtjcAAbY4AAG2OgABtjwAAbY9AAG2PgABtkAAAbZBAAG2jAABtq8AAbbPAAG27wABtvEAAbbzAAG29QABtvcAAbb6AAG2+wABtvwAAbb/AAG3AAABtwIAAbcDAAG3BQABtwgAAbcJAAG3CgABtw0AAbcOAAG3EwABtyAAAbclAAG3JwABtykAAbcuAAG3MQABtzQAAbc2AAG3WwABt38AAbemAAG3ygABt80AAbfPAAG30QABt9MAAbfVAAG31wABt9gAAbfbAAG36AABt/kAAbf7AAG3/QABt/8AAbgBAAG4AwABuAUAAbgHAAG4CQABuBoAAbgdAAG4IAABuCMAAbgmAAG4KQABuCwAAbgvAAG4MgABuDQAAbhzAAG4dQABuHcAAbh5AAG4fAABuH0AAbh+AAG4fwABuIAAAbiCAAG4hAABuIUAAbiGAAG4iAABuIkAAbjIAAG4ygABuMwAAbjOAAG40QABuNIAAbjTAAG41AABuNUAAbjXAAG42QABuNoAAbjbAAG43QABuN4AAbkdAAG5HwABuSIAAbkkAAG5JwABuSgAAbkpAAG5KgABuSsAAbktAAG5LwABuTAAAbkxAAG5MwABuTQAAblBAAG5QgABuUMAAblFAAG5hAABuYYAAbmIAAG5igABuY0AAbmOAAG5jwABuZAAAbmRAAG5kwABuZUAAbmWAAG5lwABuZkAAbmaAAG52QABudsAAbndAAG53wABueIAAbnjAAG55AABueUAAbnmAAG56AABueoAAbnrAAG57AABue4AAbnvAAG6LgABujAAAboyAAG6NAABujcAAbo4AAG6OQABujoAAbo7AAG6PQABuj8AAbpAAAG6QQABukMAAbpEAAG6gwABuoUAAbqHAAG6iQABuowAAbqNAAG6jgABuo8AAbqQAAG6kgABupQAAbqVAAG6lgABupgAAbqZAAG62AAButoAAbrcAAG63gABuuEAAbriAAG64wABuuQAAbrlAAG65wABuukAAbrqAAG66wABuu0AAbruAAG7EwABuzcAAbteAAG7ggABu4UAAbuHAAG7iQABu4sAAbuNAAG7jwABu5AAAbuTAAG7oAABu68AAbuxAAG7swABu7UAAbu3AAG7uQABu7sAAbu9AAG7zAABu88AAbvSAAG71QABu9gAAbvbAAG73gABu+EAAbvjAAG8IgABvCQAAbwmAAG8KAABvCsAAbwsAAG8LQABvC4AAbwvAAG8MQABvDMAAbw0AAG8NQABvDcAAbw4AAG8dwABvHkAAbx7AAG8fQABvIAAAbyBAAG8ggABvIMAAbyEAAG8hgABvIgAAbyJAAG8igABvIwAAbyNAAG8zAABvM4AAbzQAAG80gABvNUAAbzWAAG81wABvNgAAbzZAAG82wABvN0AAbzeAAG83wABvOEAAbziAAG9IQABvSMAAb0mAAG9KAABvSsAAb0sAAG9LQABvS4AAb0vAAG9MQABvTMAAb00AAG9NQABvTcAAb04AAG9dwABvXkAAb17AAG9fQABvYAAAb2BAAG9ggABvYMAAb2EAAG9hgABvYgAAb2JAAG9igABvYwAAb2NAAG9zAABvc4AAb3QAAG90gABvdUAAb3WAAG91wABvdgAAb3ZAAG92wABvd0AAb3eAAG93wABveEAAb3iAAG+IQABviMAAb4mAAG+KAABvisAAb4sAAG+LQABvi4AAb4vAAG+MQABvjMAAb40AAG+NQABvjcAAb44AAG+gwABvqYAAb7GAAG+5gABvugAAb7qAAG+7AABvu4AAb7xAAG+8gABvvMAAb72AAG+9wABvvkAAb76AAG+/AABvv8AAb8AAAG/AQABvwQAAb8FAAG/CgABvxcAAb8cAAG/HgABvyAAAb8lAAG/KAABvysAAb8tAAG/UgABv3YAAb+dAAG/wQABv8QAAb/GAAG/yAABv8oAAb/MAAG/zgABv88AAb/SAAG/3wABv/AAAb/yAAG/9AABv/YAAb/4AAG/+gABv/wAAb/+AAHAAAABwBEAAcAUAAHAFwABwBoAAcAdAAHAIAABwCMAAcAmAAHAKQABwCsAAcBqAAHAbAABwG4AAcBwAAHAcwABwHQAAcB1AAHAdgABwHcAAcB5AAHAewABwHwAAcB9AAHAfwABwIAAAcC/AAHAwQABwMMAAcDFAAHAyAABwMkAAcDKAAHAywABwMwAAcDOAAHA0AABwNEAAcDSAAHA1AABwNUAAcEUAAHBFgABwRkAAcEbAAHBHgABwR8AAcEgAAHBIQABwSIAAcEkAAHBJgABwScAAcEoAAHBKgABwSsAAcE4AAHBOQABwToAAcE8AAHBewABwX0AAcF/AAHBgQABwYQAAcGFAAHBhgABwYcAAcGIAAHBigABwYwAAcGNAAHBjgABwZAAAcGRAAHB0AABwdIAAcHUAAHB1gABwdkAAcHaAAHB2wABwdwAAcHdAAHB3wABweEAAcHiAAHB4wABweUAAcHmAAHCJQABwicAAcIpAAHCKwABwi4AAcIvAAHCMAABwjEAAcIyAAHCNAABwjYAAcI3AAHCOAABwjoAAcI7AAHCegABwnwAAcJ/AAHCgQABwoQAAcKFAAHChgABwocAAcKIAAHCigABwowAAcKNAAHCjgABwpAAAcKRAAHCnwABwt4AAcLgAAHC4gABwuQAAcLnAAHC6AABwukAAcLqAAHC6wABwu0AAcLvAAHC8AABwvEAAcLzAAHC9AABwxkAAcM9AAHDZAABw4gAAcOLAAHDjQABw48AAcORAAHDkwABw5UAAcOWAAHDmQABw6YAAcO1AAHDtwABw7kAAcO7AAHDvQABw78AAcPBAAHDwwABw9IAAcPVAAHD2AABw9sAAcPeAAHD4QABw+QAAcPnAAHD6QABxCgAAcQqAAHELAABxC4AAcQxAAHEMgABxDMAAcQ0AAHENQABxDcAAcQ5AAHEOgABxDsAAcQ9AAHEPgABxH0AAcR/AAHEgQABxIMAAcSGAAHEhwABxIgAAcSJAAHEigABxIwAAcSOAAHEjwABxJAAAcSSAAHEkwABxNIAAcTUAAHE1gABxNgAAcTbAAHE3AABxN0AAcTeAAHE3wABxOEAAcTjAAHE5AABxOUAAcTnAAHE6AABxScAAcUpAAHFKwABxS0AAcUwAAHFMQABxTIAAcUzAAHFNAABxTYAAcU4AAHFOQABxToAAcU8AAHFPQABxXwAAcV+AAHFgAABxYIAAcWFAAHFhgABxYcAAcWIAAHFiQABxYsAAcWNAAHFjgABxY8AAcWRAAHFkgABxdEAAcXTAAHF1QABxdcAAcXaAAHF2wABxdwAAcXdAAHF3gABxeAAAcXiAAHF4wABxeQAAcXmAAHF5wABxiYAAcYoAAHGKgABxiwAAcYvAAHGMAABxjEAAcYyAAHGMwABxjUAAcY3AAHGOAABxjkAAcY7AAHGPAABxocAAcaqAAHGygABxuoAAcbsAAHG7gABxvAAAcbyAAHG9QABxvYAAcb3AAHG+gABxvsAAcb9AAHG/gABxwAAAccDAAHHBAABxwUAAccIAAHHCQABxw4AAccbAAHHIAABxyIAAcckAAHHKQABxywAAccvAAHHMQABx1YAAcd6AAHHoQABx8UAAcfIAAHHygABx8wAAcfOAAHH0AABx9IAAcfTAAHH1gABx+MAAcf0AAHH9gABx/gAAcf6AAHH/AABx/4AAcgAAAHIAgAByAQAAcgVAAHIGAAByBsAAcgeAAHIIQAByCQAAcgnAAHIKgAByC0AAcgvAAHIbgAByHAAAchyAAHIdAAByHcAAch4AAHIeQAByHoAAch7AAHIfQAByH8AAciAAAHIgQAByIMAAciEAAHIwwAByMUAAcjHAAHIyQAByMwAAcjNAAHIzgAByM8AAcjQAAHI0gAByNQAAcjVAAHI1gAByNgAAcjZAAHJGAAByRoAAckdAAHJHwABySIAAckjAAHJJAABySUAAckmAAHJKAABySoAAckrAAHJLAAByS4AAckvAAHJPAAByT0AAck+AAHJQAAByX8AAcmBAAHJgwAByYUAAcmIAAHJiQAByYoAAcmLAAHJjAAByY4AAcmQAAHJkQAByZIAAcmUAAHJlQABydQAAcnWAAHJ2AABydoAAcndAAHJ3gAByd8AAcngAAHJ4QAByeMAAcnlAAHJ5gAByecAAcnpAAHJ6gAByikAAcorAAHKLQAByi8AAcoyAAHKMwAByjQAAco1AAHKNgAByjgAAco6AAHKOwAByjwAAco+AAHKPwAByn4AAcqAAAHKggAByoQAAcqHAAHKiAAByokAAcqKAAHKiwAByo0AAcqPAAHKkAABypEAAcqTAAHKlAABytMAAcrVAAHK1wABytkAAcrcAAHK3QAByt4AAcrfAAHK4AAByuIAAcrkAAHK5QAByuYAAcroAAHK6QAByw4AAcsyAAHLWQABy30AAcuAAAHLggABy4QAAcuGAAHLiAABy4oAAcuLAAHLjgABy5sAAcuqAAHLrAABy64AAcuwAAHLsgABy7QAAcu2AAHLuAABy8cAAcvKAAHLzQABy9AAAcvTAAHL1gABy9kAAcvcAAHL3gABzB0AAcwfAAHMIQABzCMAAcwmAAHMJwABzCgAAcwpAAHMKgABzCwAAcwuAAHMLwABzDAAAcwyAAHMMwABzHIAAcx0AAHMdgABzHgAAcx7AAHMfAABzH0AAcx+AAHMfwABzIEAAcyDAAHMhAABzIUAAcyHAAHMiAABzMcAAczJAAHMywABzM0AAczQAAHM0QABzNIAAczTAAHM1AABzNYAAczYAAHM2QABzNoAAczcAAHM3QABzRwAAc0eAAHNIAABzSIAAc0lAAHNJgABzScAAc0oAAHNKQABzSsAAc0tAAHNLgABzS8AAc0xAAHNMgABzXEAAc1zAAHNdQABzXcAAc16AAHNewABzXwAAc19AAHNfgABzYAAAc2CAAHNgwABzYQAAc2GAAHNhwABzcYAAc3IAAHNygABzcwAAc3PAAHN0AABzdEAAc3SAAHN0wABzdUAAc3XAAHN2AABzdkAAc3bAAHN3AABzhsAAc4dAAHOHwABziEAAc4kAAHOJQABziYAAc4nAAHOKAABzioAAc4sAAHOLQABzi4AAc4wAAHOMQABznwAAc6fAAHOvwABzt8AAc7hAAHO4wABzuUAAc7nAAHO6gABzusAAc7sAAHO7wABzvAAAc7yAAHO8wABzvUAAc74AAHO+QABzvoAAc79AAHO/gABzwMAAc8QAAHPFQABzxcAAc8ZAAHPHgABzyEAAc8kAAHPJgABz0sAAc9vAAHPlgABz7oAAc+9AAHPvwABz8EAAc/DAAHPxQABz8cAAc/IAAHPywABz9gAAc/pAAHP6wABz+0AAc/vAAHP8QABz/MAAc/1AAHP9wABz/kAAdAKAAHQDQAB0BAAAdATAAHQFgAB0BkAAdAcAAHQHwAB0CIAAdAkAAHQYwAB0GUAAdBnAAHQaQAB0GwAAdBtAAHQbgAB0G8AAdBwAAHQcgAB0HQAAdB1AAHQdgAB0HgAAdB5AAHQuAAB0LoAAdC8AAHQvgAB0MEAAdDCAAHQwwAB0MQAAdDFAAHQxwAB0MkAAdDKAAHQywAB0M0AAdDOAAHRDQAB0Q8AAdESAAHRFAAB0RcAAdEYAAHRGQAB0RoAAdEbAAHRHQAB0R8AAdEgAAHRIQAB0SMAAdEkAAHRMQAB0TIAAdEzAAHRNQAB0XQAAdF2AAHReAAB0XoAAdF9AAHRfgAB0X8AAdGAAAHRgQAB0YMAAdGFAAHRhgAB0YcAAdGJAAHRigAB0ckAAdHLAAHRzQAB0c8AAdHSAAHR0wAB0dQAAdHVAAHR1gAB0dgAAdHaAAHR2wAB0dwAAdHeAAHR3wAB0h4AAdIgAAHSIgAB0iQAAdInAAHSKAAB0ikAAdIqAAHSKwAB0i0AAdIvAAHSMAAB0jEAAdIzAAHSNAAB0nMAAdJ1AAHSdwAB0nkAAdJ8AAHSfQAB0n4AAdJ/AAHSgAAB0oIAAdKEAAHShQAB0oYAAdKIAAHSiQAB0sgAAdLKAAHSzAAB0s4AAdLRAAHS0gAB0tMAAdLUAAHS1QAB0tcAAdLZAAHS2gAB0tsAAdLdAAHS3gAB0wMAAdMnAAHTTgAB03IAAdN1AAHTdwAB03kAAdN7AAHTfQAB038AAdOAAAHTgwAB05AAAdOfAAHToQAB06MAAdOlAAHTpwAB06kAAdOrAAHTrQAB07wAAdO/AAHTwgAB08UAAdPIAAHTywAB084AAdPRAAHT0wAB1BIAAdQUAAHUFgAB1BgAAdQbAAHUHAAB1B0AAdQeAAHUHwAB1CEAAdQjAAHUJAAB1CUAAdQnAAHUKAAB1GcAAdRpAAHUawAB1G0AAdRwAAHUcQAB1HIAAdRzAAHUdAAB1HYAAdR4AAHUeQAB1HoAAdR8AAHUfQAB1LwAAdS+AAHUwAAB1MIAAdTFAAHUxgAB1McAAdTIAAHUyQAB1MsAAdTNAAHUzgAB1M8AAdTRAAHU0gAB1REAAdUTAAHVFQAB1RcAAdUaAAHVGwAB1RwAAdUdAAHVHgAB1SAAAdUiAAHVIwAB1SQAAdUmAAHVJwAB1WYAAdVoAAHVagAB1WwAAdVvAAHVcAAB1XEAAdVyAAHVcwAB1XUAAdV3AAHVeAAB1XkAAdV7AAHVfAAB1bsAAdW9AAHVvwAB1cEAAdXEAAHVxQAB1cYAAdXHAAHVyAAB1coAAdXMAAHVzQAB1c4AAdXQAAHV0QAB1hAAAdYSAAHWFAAB1hYAAdYZAAHWGgAB1hsAAdYcAAHWHQAB1h8AAdYhAAHWIgAB1iMAAdYlAAHWJgAB1i8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AAfJYAAHyWQAB8loAAfJbAAHyXQAB8l8AAfJgAAHyYQAB8mMAAfJkAAHyowAB8qUAAfKnAAHyqQAB8qwAAfKtAAHyrgAB8q8AAfKwAAHysgAB8rQAAfK1AAHytgAB8rgAAfK5AAHy+AAB8voAAfL9AAHy/wAB8wIAAfMDAAHzBAAB8wUAAfMGAAHzCAAB8woAAfMLAAHzDAAB8w4AAfMPAAHzHAAB8x0AAfMeAAHzIAAB818AAfNhAAHzYwAB82UAAfNoAAHzaQAB82oAAfNrAAHzbAAB824AAfNwAAHzcQAB83IAAfN0AAHzdQAB87QAAfO2AAHzuAAB87oAAfO9AAHzvgAB878AAfPAAAHzwQAB88MAAfPFAAHzxgAB88cAAfPJAAHzygAB9AkAAfQLAAH0DQAB9A8AAfQSAAH0EwAB9BQAAfQVAAH0FgAB9BgAAfQaAAH0GwAB9BwAAfQeAAH0HwAB9F4AAfRgAAH0YgAB9GQAAfRnAAH0aAAB9GkAAfRqAAH0awAB9G0AAfRvAAH0cAAB9HEAAfRzAAH0dAAB9LMAAfS1AAH0twAB9LkAAfS8AAH0vQAB9L4AAfS/AAH0wAAB9MIAAfTEAAH0xQAB9MYAAfTIAAH0yQAB9O4AAfUSAAH1OQAB9V0AAfVgAAH1YgAB9WQAAfVmAAH1aAAB9WoAAfVrAAH1bgAB9XsAAfWKAAH1jAAB9Y4AAfWQAAH1kgAB9ZQAAfWWAAH1mAAB9acAAfWqAAH1rQAB9bAAAfWzAAH1tgAB9bkAAfW8AAH1vgAB9f0AAfX/AAH2AQAB9gMAAfYGAAH2BwAB9ggAAfYJAAH2CgAB9gwAAfYOAAH2DwAB9hAAAfYSAAH2EwAB9lIAAfZUAAH2VgAB9lgAAfZbAAH2XAAB9l0AAfZeAAH2XwAB9mEAAfZjAAH2ZAAB9mUAAfZnAAH2aAAB9qcAAfapAAH2qwAB9q0AAfawAAH2sQAB9rIAAfazAAH2tAAB9rYAAfa4AAH2uQAB9roAAfa8AAH2vQAB9vwAAfb+AAH3AQAB9wMAAfcGAAH3BwAB9wgAAfcJAAH3CgAB9wwAAfcOAAH3DwAB9xAAAfcSAAH3EwAB91IAAfdUAAH3VgAB91gAAfdbAAH3XAAB910AAfdeAAH3XwAB92EAAfdjAAH3ZAAB92UAAfdnAAH3aAAB96c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AAgL3AAIC+AACAvkAAgL7AAIC/QACAv4AAgL/AAIDAQACAwIAAgMPAAIDEAACAxEAAgMTAAIDUgACA1QAAgNWAAIDWAACA1sAAgNcAAIDXQACA14AAgNfAAIDYQACA2MAAgNkAAIDZQACA2cAAgNoAAIDpwACA6kAAgOrAAIDrQACA7AAAgOxAAIDsgACA7MAAgO0AAIDtgACA7gAAgO5AAIDugACA7wAAgO9AAID/AACA/4AAgQAAAIEAgACBAUAAgQGAAIEBwACBAgAAgQJAAIECwACBA0AAgQOAAIEDwACBBEAAgQSAAIEUQACBFMAAgRVAAIEVwACBFoAAgRbAAIEXAACBF0AAgReAAIEYAACBGIAAgRjAAIEZAACBGYAAgRnAAIEpgACBKgAAgSqAAIErAACBK8AAgSwAAIEsQACBLIAAgSzAAIEtQACBLcAAgS4AAIEuQACBLsAAgS8AAIE4QACBQUAAgUsAAIFUAACBVMAAgVVAAIFVwACBVkAAgVbAAIFXQACBV4AAgVhAAIFbgACBX0AAgV/AAIFgQACBYMAAgWFAAIFhwACBYkAAgWLAAIFmgACBZ0AAgWgAAIFowACBaYAAgWpAAIFrAACBa8AAgWxAAIF8AACBfIAAgX0AAIF9gACBfkAAgX6AAIF+wACBfwAAgX9AAIF/wACBgEAAgYCAAIGAwACBgUAAgYGAAIGRQACBkcAAgZJAAIGSwACBk4AAgZPAAIGUAACBlEAAgZSAAIGVAACBlYAAgZXAAIGWAACBloAAgZbAAIGmgACBpwAAgaeAAIGoAACBqMAAgakAAIGpQACBqYAAganAAIGqQACBqsAAgasAAIGrQACBq8AAgawAAIG7wACBvEAAgb0AAIG9gACBvkAAgb6AAIG+wACBvwAAgb9AAIG/wACBwEAAgcCAAIHAwACBwUAAgcGAAIHSQACB20AAgeRAAIHtAACB9sAAgf7AAIIIgACCEkAAghpAAIIjQACCLEAAgizAAIItgACCLgAAgi6AAIIvAACCL8AAgjCAAIIxAACCMYAAgjJAAIIywACCM0AAgjQAAII0wACCNQAAgjZAAII5gACCOkAAgjrAAII7gACCPEAAgjzAAIJGAACCTwAAgljAAIJhwACCYoAAgmMAAIJjgACCZAAAgmSAAIJlAACCZUAAgmYAAIJpQACCbgAAgm6AAIJvAACCb4AAgnAAAIJwgACCcQAAgnGAAIJyAACCcoAAgndAAIJ4AACCeMAAgnmAAIJ6QACCewAAgnvAAIJ8gACCfUAAgn4AAIJ+gACCjkAAgo7AAIKPgACCkAAAgpDAAIKRAACCkUAAgpGAAIKRwACCkkAAgpLAAIKTAACCk0AAgpPAAIKUAACClkAAgpaAAIKXAACCpsAAgqdAAIKnwACCqEAAgqkAAIKpQACCqYAAgqnAAIKqAACCqoAAgqsAAIKrQACCq4AAgqwAAIKsQACCvAAAgryAAIK9QACCvcAAgr6AAIK+wACCvwAAgr9AAIK/gACCwAAAgsCAAILAwACCwQAAgsGAAILBwACCxAAAgsRAAILEwACC1IAAgtUAAILVgACC1gAAgtbAAILXAACC10AAgteAAILXwACC2EAAgtjAAILZAACC2UAAgtnAAILaAACC6cAAgupAAILrAACC64AAguxAAILsgACC7MAAgu0AAILtQACC7cAAgu5AAILugACC7sAAgu9AAILvgACC8cAAgvIAAILygACDAkAAgwLAAIMDQACDA8AAgwSAAIMEwACDBQAAgwVAAIMFgACDBgAAgwaAAIMGwACDBwAAgweAAIMHwACDF4AAgxgAAIMYwACDGUAAgxoAAIMaQACDGoAAgxrAAIMbAACDG4AAgxwAAIMcQACDHIAAgx0AAIMdQACDIIAAgyDAAIMhAACDIYAAgzFAAIMxwACDMkAAgzLAAIMzgACDM8AAgzQAAIM0QACDNIAAgzUAAIM1gACDNcAAgzYAAIM2gACDNsAAg0aAAINHAACDR8AAg0hAAINJAACDSUAAg0mAAINJwACDSgAAg0qAAINLAACDS0AAg0uAAINMAACDTEAAg1EAAINUQACDWIAAg1lAAINaAACDWsAAg1tAAINcAACDXMAAg12AAINeQACDYoAAg2NAAINkAACDZMAAg2WAAINmQACDZwAAg2fAAINogACDaQAAg2rAAINtgACDcQAAg3RAAIN3AACDekAAg31AAIOQAACDmMAAg6DAAIOowACDqUAAg6nAAIOqQACDqsAAg6uAAIOrwACDrAAAg6zAAIOtAACDrYAAg63AAIOuQACDrwAAg69AAIOvgACDsEAAg7CAAIOxwACDtQAAg7ZAAIO2wACDt0AAg7iAAIO5QACDugAAg7qAAIPDwACDzMAAg9aAAIPfgACD4EAAg+DAAIPhQACD4cAAg+JAAIPiwACD4wAAg+PAAIPnAACD60AAg+vAAIPsQACD7MAAg+1AAIPtwACD7kAAg+7AAIPvQACD84AAg/RAAIP1AACD9cAAg/aAAIP3QACD+AAAg/jAAIP5gACD+gAAhAnAAIQKQACECsAAhAtAAIQMAACEDEAAhAyAAIQMwACEDQAAhA2AAIQOAACEDkAAhA6AAIQPAACED0AAhB8AAIQfgACEIAAAhCCAAIQhQACEIYAAhCHAAIQiAACEIkAAhCLAAIQjQACEI4AAhCPAAIQkQACEJIAAhDRAAIQ0wACENYAAhDYAAIQ2wACENwAAhDdAAIQ3gACEN8AAhDhAAIQ4wACEOQAAhDlAAIQ5wACEOgAAhD1AAIQ9gACEPcAAhD5AAIROAACEToAAhE8AAIRPgACEUEAAhFCAAIRQwACEUQAAhFFAAIRRwACEUkAAhFKAAIRSwACEU0AAhFOAAIRjQACEY8AAhGRAAIRkwACEZYAAhGXAAIRmAACEZkAAhGaAAIRnAACEZ4AAhGfAAIRoAACEaIAAhGjAAIR4gACEeQAAhHmAAIR6AACEesAAhHsAAIR7QACEe4AAhHvAAIR8QACEfMAAhH0AAIR9QACEfcAAhH4AAISNwACEjkAAhI7AAISPQACEkAAAhJBAAISQgACEkMAAhJEAAISRgACEkgAAhJJAAISSgACEkwAAhJNAAISjAACEo4AAhKQAAISkgACEpUAAhKWAAISlwACEpgAAhKZAAISmwACEp0AAhKeAAISnwACEqEAAhKiAAISxwACEusAAhMSAAITNgACEzkAAhM7AAITPQACEz8AAhNBAAITQwACE0QAAhNHAAITVAACE2MAAhNlAAITZwACE2kAAhNrAAITbQACE28AAhNxAAITgAACE4MAAhOGAAITiQACE4wAAhOPAAITkgACE5UAAhOXAAIT1gACE9gAAhPaAAIT3AACE98AAhPgAAIT4QACE+IAAhPjAAIT5QACE+cAAhPoAAIT6QACE+sAAhPsAAIUKwACFC0AAhQvAAIUMQACFDQAAhQ1AAIUNgACFDcAAhQ4AAIUOgACFDwAAhQ9AAIUPgACFEAAAhRBAAIUgAACFIIAAhSEAAIUhgACFIkAAhSKAAIUiwACFIwAAhSNAAIUjwACFJEAAhSSAAIUkwACFJUAAhSWAAIU1QACFNcAAhTZAAIU2wACFN4AAhTfAAIU4AACFOEAAhTiAAIU5AACFOYAAhTnAAIU6AACFOoAAhTrAAIVKgACFSwAAhUuAAIVMAACFTMAAhU0AAIVNQACFTYAAhU3AAIVOQACFTsAAhU8AAIVPQACFT8AAhVAAAIVfwACFYEAAhWDAAIVhQACFYgAAhWJAAIVigACFYsAAhWMAAIVjgACFZAAAhWRAAIVkgACFZQAAhWVAAIV1AACFdYAAhXYAAIV2gACFd0AAhXeAAIV3wACFeAAAhXhAAIV4wACFeUAAhXmAAIV5wACFekAAhXqAAIWNQACFlgAAhZ4AAIWmAACFpoAAhacAAIWngACFqAAAhajAAIWpAACFqUAAhaoAAIWqQACFqsAAhasAAIWrgACFrEAAhayAAIWswACFrYAAha3AAIWwAACFs0AAhbSAAIW1AACFtYAAhbbAAIW3gACFuEAAhbjAAIXCAACFywAAhdTAAIXdwACF3oAAhd8AAIXfgACF4AAAheCAAIXhAACF4UAAheIAAIXlQACF6YAAheoAAIXqgACF6wAAheuAAIXsAACF7IAAhe0AAIXtgACF8cAAhfKAAIXzQACF9AAAhfTAAIX1gACF9kAAhfcAAIX3wACF+EAAhggAAIYIgACGCQAAhgmAAIYKQACGCoAAhgrAAIYLAACGC0AAhgvAAIYMQACGDIAAhgzAAIYNQACGDYAAhh1AAIYdwACGHkAAhh7AAIYfgACGH8AAhiAAAIYgQACGIIAAhiEAAIYhgACGIcAAhiIAAIYigACGIsAAhjKAAIYzAACGM8AAhjRAAIY1AACGNUAAhjWAAIY1wACGNgAAhjaAAIY3AACGN0AAhjeAAIY4AACGOEAAhjuAAIY7wACGPAAAhjyAAIZMQACGTMAAhk1AAIZNwACGToAAhk7AAIZPAACGT0AAhk+AAIZQAACGUIAAhlDAAIZRAACGUYAAhlHAAIZhgACGYgAAhmKAAIZjAACGY8AAhmQAAIZkQACGZIAAhmTAAIZlQACGZcAAhmYAAIZmQACGZsAAhmcAAIZ2wACGd0AAhnfAAIZ4QACGeQAAhnlAAIZ5gACGecAAhnoAAIZ6gACGewAAhntAAIZ7gACGfAAAhnxAAIaMAACGjIAAho0AAIaNgACGjkAAho6AAIaOwACGjwAAho9AAIaPwACGkEAAhpCAAIaQwACGkUAAhpGAAIahQACGocAAhqJAAIaiwACGo4AAhqPAAIakAACGpEAAhqSAAIalAACGpYAAhqXAAIamAACGpoAAhqbAAIawAACGuQAAhsLAAIbLwACGzIAAhs0AAIbNgACGzgAAhs6AAIbPAACGz0AAhtAAAIbTQACG1wAAhteAAIbYAACG2IAAhtkAAIbZgACG2gAAhtqAAIbeQACG3wAAht/AAIbggACG4UAAhuIAAIbiwACG44AAhuQAAIbzwACG9EAAhvTAAIb1QACG9gAAhvZAAIb2gACG9sAAhvcAAIb3gACG+AAAhvhAAIb4gACG+QAAhvlAAIcJAACHCYAAhwoAAIcKgACHC0AAhwuAAIcLwACHDAAAhwxAAIcMwACHDUAAhw2AAIcNwACHDkAAhw6AAIceQACHHsAAhx9AAIcfwACHIIAAhyDAAIchAACHIUAAhyGAAIciAACHIoAAhyLAAIcjAACHI4AAhyPAAIczgACHNAAAhzSAAIc1AACHNcAAhzYAAIc2QACHNoAAhzbAAIc3QACHN8AAhzgAAIc4QACHOMAAhzkAAIdIwACHSUAAh0nAAIdKQACHSwAAh0tAAIdLgACHS8AAh0wAAIdMgACHTQAAh01AAIdNgACHTgAAh05AAIdeAACHXoAAh18AAIdfgACHYEAAh2CAAIdgwACHYQAAh2FAAIdhwACHYkAAh2KAAIdiwACHY0AAh2OAAIdzQACHc8AAh3RAAId0wACHdYAAh3XAAId2AACHdkAAh3aAAId3AACHd4AAh3fAAId4AACHeIAAh3jAAIeLgACHlEAAh5xAAIekQACHpMAAh6VAAIelwACHpkAAh6cAAIenQACHp4AAh6hAAIeogACHqQAAh6lAAIepwACHqoAAh6rAAIerAACHq8AAh6wAAIetQACHsIAAh7HAAIeyQACHssAAh7QAAIe0wACHtYAAh7YAAIe/QACHyEAAh9IAAIfbAACH28AAh9xAAIfcwACH3UAAh93AAIfeQACH3oAAh99AAIfigACH5sAAh+dAAIfnwACH6EAAh+jAAIfpQACH6cAAh+pAAIfqwACH7wAAh+/AAIfwgACH8UAAh/IAAIfywACH84AAh/RAAIf1AACH9YAAiAVAAIgFwACIBkAAiAbAAIgHgACIB8AAiAgAAIgIQACICIAAiAkAAIgJgACICcAAiAoAAIgKgACICsAAiBqAAIgbAACIG4AAiBwAAIgcwACIHQAAiB1AAIgdgACIHcAAiB5AAIgewACIHwAAiB9AAIgfwACIIAAAiC/AAIgwQACIMQAAiDGAAIgyQACIMoAAiDLAAIgzAACIM0AAiDPAAIg0QACINIAAiDTAAIg1QACINYAAiDjAAIg5AACIOUAAiDnAAIhJgACISgAAiEqAAIhLAACIS8AAiEwAAIhMQACITIAAiEzAAIhNQACITcAAiE4AAIhOQACITsAAiE8AAIhewACIX0AAiF/AAIhgQACIYQAAiGFAAIhhgACIYcAAiGIAAIhigACIYwAAiGNAAIhjgACIZAAAiGRAAIh0AACIdIAAiHUAAIh1gACIdkAAiHaAAIh2wACIdwAAiHdAAIh3wACIeEAAiHiAAIh4wACIeUAAiHmAAIiJQACIicAAiIpAAIiKwACIi4AAiIvAAIiMAACIjEAAiIyAAIiNAACIjYAAiI3AAIiOAACIjoAAiI7AAIiegACInwAAiJ+AAIigAACIoMAAiKEAAIihQACIoYAAiKHAAIiiQACIosAAiKMAAIijQACIo8AAiKQAAIitQACItkAAiMAAAIjJAACIycAAiMpAAIjKwACIy0AAiMvAAIjMQACIzIAAiM1AAIjQgACI1EAAiNTAAIjVQACI1cAAiNZAAIjWwACI10AAiNfAAIjbgACI3EAAiN0AAIjdwACI3oAAiN9AAIjgAACI4MAAiOFAAIjxAACI8YAAiPIAAIjygACI80AAiPOAAIjzwACI9AAAiPRAAIj0wACI9UAAiPWAAIj1wACI9kAAiPaAAIkGQACJBsAAiQdAAIkHwACJCIAAiQjAAIkJAACJCUAAiQmAAIkKAACJCoAAiQrAAIkLAACJC4AAiQvAAIkbgACJHAAAiRyAAIkdAACJHcAAiR4AAIkeQACJHoAAiR7AAIkfQACJH8AAiSAAAIkgQACJIMAAiSEAAIkwwACJMUAAiTIAAIkygACJM0AAiTOAAIkzwACJNAAAiTRAAIk0wACJNUAAiTWAAIk1wACJNkAAiTaAAIlGQACJRsAAiUdAAIlHwACJSIAAiUjAAIlJAACJSUAAiUmAAIlKAACJSoAAiUrAAIlLAACJS4AAiUvAAIlbgACJXAAAiVyAAIldAACJXcAAiV4AAIleQACJXoAAiV7AAIlfQACJX8AAiWAAAIlgQACJYMAAiWEAAIlwwACJcUAAiXHAAIlyQACJcwAAiXNAAIlzgACJc8AAiXQAAIl0gACJdQAAiXVAAIl1gACJdgAAiXZAAImJAACJkcAAiZnAAImhwACJokAAiaLAAImjQACJo8AAiaSAAImkwACJpQAAiaXAAImmAACJpoAAiabAAImnQACJp8AAiagAAImoQACJqQAAialAAImqgACJrcAAia8AAImvgACJsAAAibFAAImyAACJssAAibNAAIm8gACJxYAAic9AAInYQACJ2QAAidmAAInaAACJ2oAAidsAAInbgACJ28AAidyAAInfwACJ5AAAieSAAInlAACJ5YAAieYAAInmgACJ5wAAieeAAInoAACJ7EAAie0AAIntwACJ7oAAie9AAInwAACJ8MAAifGAAInyQACJ8sAAigKAAIoDAACKA4AAigQAAIoEwACKBQAAigVAAIoFgACKBcAAigZAAIoGwACKBwAAigdAAIoHwACKCAAAihfAAIoYQACKGMAAihlAAIoaAACKGkAAihqAAIoawACKGwAAihuAAIocAACKHEAAihyAAIodAACKHUAAii0AAIotgACKLkAAii7AAIovgACKL8AAijAAAIowQACKMIAAijEAAIoxgACKMcAAijIAAIoygACKMsAAijYAAIo2QACKNoAAijcAAIpGwACKR0AAikfAAIpIQACKSQAAiklAAIpJgACKScAAikoAAIpKgACKSwAAiktAAIpLgACKTAAAikxAAIpcAACKXIAAil0AAIpdgACKXkAAil6AAIpewACKXwAAil9AAIpfwACKYEAAimCAAIpgwACKYUAAimGAAIpxQACKccAAinJAAIpywACKc4AAinPAAIp0AACKdEAAinSAAIp1AACKdYAAinXAAIp2AACKdoAAinbAAIqGgACKhwAAioeAAIqIAACKiMAAiokAAIqJQACKiYAAionAAIqKQACKisAAiosAAIqLQACKi8AAiowAAIqbwACKnEAAipzAAIqdQACKngAAip5AAIqegACKnsAAip8AAIqfgACKoAAAiqBAAIqggACKoQAAiqFAAIqqgACKs4AAir1AAIrGQACKxwAAiseAAIrIAACKyIAAiskAAIrJgACKycAAisqAAIrNwACK0YAAitIAAIrSgACK0wAAitOAAIrUAACK1IAAitUAAIrYwACK2YAAitpAAIrbAACK28AAityAAIrdQACK3gAAit6AAIruQACK7sAAiu+AAIrwAACK8MAAivEAAIrxQACK8YAAivHAAIryQACK8sAAivMAAIrzQACK88AAivQAAIsDwACLBEAAiwTAAIsFQACLBgAAiwZAAIsGgACLBsAAiwcAAIsHgACLCAAAiwhAAIsIgACLCQAAiwlAAIsZAACLGYAAixoAAIsagACLG0AAixuAAIsbwACLHAAAixxAAIscwACLHUAAix2AAIsdwACLHkAAix6AAIsuQACLLsAAiy+AAIswAACLMMAAizEAAIsxQACLMYAAizHAAIsyQACLMsAAizMAAIszQACLM8AAizQAAItDwACLREAAi0TAAItFQACLRgAAi0ZAAItGgACLRsAAi0cAAItHgACLSAAAi0hAAItIgACLSQAAi0lAAItZAACLWYAAi1oAAItagACLW0AAi1uAAItbwACLXAAAi1xAAItcwACLXUAAi12AAItdwACLXkAAi16AAItuQACLbsAAi29AAItvwACLcIAAi3DAAItxAACLcUAAi3GAAItyAACLcoAAi3LAAItzAACLc4AAi3PAAIuGgACLj0AAi5dAAIufQACLn8AAi6BAAIugwACLoUAAi6IAAIuiQACLooAAi6NAAIujgACLpAAAi6RAAIukwACLpYAAi6XAAIumAACLpsAAi6cAAIuoQACLq4AAi6zAAIutQACLrcAAi68AAIuvwACLsIAAi7EAAIu6QACLw0AAi80AAIvWAACL1sAAi9dAAIvXwACL2EAAi9jAAIvZQACL2YAAi9pAAIvdgACL4cAAi+JAAIviwACL40AAi+PAAIvkQACL5MAAi+VAAIvlwACL6gAAi+rAAIvrgACL7EAAi+0AAIvtwACL7oAAi+9AAIvwAACL8IAAjABAAIwAwACMAUAAjAHAAIwCgACMAsAAjAMAAIwDQACMA4AAjAQAAIwEgACMBMAAjAUAAIwFgACMBcAAjBWAAIwWAACMFoAAjBcAAIwXwACMGAAAjBhAAIwYgACMGMAAjBlAAIwZwACMGgAAjBpAAIwawACMGwAAjCrAAIwrQACMLAAAjCyAAIwtQACMLYAAjC3AAIwuAACMLkAAjC7AAIwvQACML4AAjC/AAIwwQACMMIAAjDPAAIw0AACMNEAAjDTAAIxEgACMRQAAjEWAAIxGAACMRsAAjEcAAIxHQACMR4AAjEfAAIxIQACMSMAAjEkAAIxJQACMScAAjEoAAIxZwACMWkAAjFrAAIxbQACMXAAAjFxAAIxcgACMXMAAjF0AAIxdgACMXgAAjF5AAIxegACMXwAAjF9AAIxvAACMb4AAjHAAAIxwgACMcUAAjHGAAIxxwACMcgAAjHJAAIxywACMc0AAjHOAAIxzwACMdEAAjHSAAIyEQACMhMAAjIVAAIyFwACMhoAAjIbAAIyHAACMh0AAjIeAAIyIAACMiIAAjIjAAIyJAACMiYAAjInAAIyZgACMmgAAjJqAAIybAACMm8AAjJwAAIycQACMnIAAjJzAAIydQACMncAAjJ4AAIyeQACMnsAAjJ8AAIyoQACMsUAAjLsAAIzEAACMxMAAjMVAAIzFwACMxkAAjMbAAIzHQACMx4AAjMhAAIzLgACMz0AAjM/AAIzQQACM0MAAjNFAAIzRwACM0kAAjNLAAIzWgACM10AAjNgAAIzYwACM2YAAjNpAAIzbAACM28AAjNxAAIzsAACM7IAAjO0AAIztgACM7kAAjO6AAIzuwACM7wAAjO9AAIzvwACM8EAAjPCAAIzwwACM8UAAjPGAAI0BQACNAcAAjQJAAI0CwACNA4AAjQPAAI0EAACNBEAAjQSAAI0FAACNBYAAjQXAAI0GAACNBoAAjQbAAI0WgACNFwAAjReAAI0YAACNGMAAjRkAAI0ZQACNGYAAjRnAAI0aQACNGsAAjRsAAI0bQACNG8AAjRwAAI0rwACNLEAAjSzAAI0tQACNLgAAjS5AAI0ugACNLsAAjS8AAI0vgACNMAAAjTBAAI0wgACNMQAAjTFAAI1BAACNQYAAjUIAAI1CgACNQ0AAjUOAAI1DwACNRAAAjURAAI1EwACNRUAAjUWAAI1FwACNRkAAjUaAAI1WQACNVsAAjVdAAI1XwACNWIAAjVjAAI1ZAACNWUAAjVmAAI1aAACNWoAAjVrAAI1bAACNW4AAjVvAAI1rgACNbAAAjWyAAI1tAACNbcAAjW4AAI1uQACNboAAjW7AAI1vQACNb8AAjXAAAI1wQACNcMAAjXEAAI2DwACNjIAAjZSAAI2cgACNnQAAjZ2AAI2eAACNnoAAjZ9AAI2fgACNn8AAjaCAAI2gwACNoUAAjaGAAI2iAACNosAAjaMAAI2jQACNpAAAjaRAAI2mgACNqcAAjasAAI2rgACNrAAAja1AAI2uAACNrsAAja9AAI24gACNwYAAjctAAI3UQACN1QAAjdWAAI3WAACN1oAAjdcAAI3XgACN18AAjdiAAI3bwACN4AAAjeCAAI3hAACN4YAAjeIAAI3igACN4wAAjeOAAI3kAACN6EAAjekAAI3pwACN6oAAjetAAI3sAACN7MAAje2AAI3uQACN7sAAjf6AAI3/AACN/4AAjgAAAI4AwACOAQAAjgFAAI4BgACOAcAAjgJAAI4CwACOAwAAjgNAAI4DwACOBAAAjhPAAI4UQACOFMAAjhVAAI4WAACOFkAAjhaAAI4WwACOFwAAjheAAI4YAACOGEAAjhiAAI4ZAACOGUAAjikAAI4pgACOKkAAjirAAI4rgACOK8AAjiwAAI4sQACOLIAAji0AAI4tgACOLcAAji4AAI4ugACOLsAAjjIAAI4yQACOMoAAjjMAAI5CwACOQ0AAjkPAAI5EQACORQAAjkVAAI5FgACORcAAjkYAAI5GgACORwAAjkdAAI5HgACOSAAAjkhAAI5YAACOWIAAjlkAAI5ZgACOWkAAjlqAAI5awACOWwAAjltAAI5bwACOXEAAjlyAAI5cwACOXUAAjl2AAI5tQACObcAAjm5AAI5uwACOb4AAjm/AAI5wAACOcEAAjnCAAI5xAACOcYAAjnHAAI5yAACOcoAAjnLAAI6CgACOgwAAjoOAAI6EAACOhMAAjoUAAI6FQACOhYAAjoXAAI6GQACOhsAAjocAAI6HQACOh8AAjogAAI6XwACOmEAAjpjAAI6ZQACOmgAAjppAAI6agACOmsAAjpsAAI6bgACOnAAAjpxAAI6cgACOnQAAjp1AAI6mgACOr4AAjrlAAI7CQACOwwAAjsOAAI7EAACOxIAAjsUAAI7FgACOxcAAjsaAAI7JwACOzYAAjs4AAI7OgACOzwAAjs+AAI7QAACO0IAAjtEAAI7UwACO1YAAjtZAAI7XAACO18AAjtiAAI7ZQACO2gAAjtqAAI7qQACO6sAAjutAAI7rwACO7IAAjuzAAI7tAACO7U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AAkccAAJHQAACR0MAAkdFAAJHRwACR0kAAkdLAAJHTQACR04AAkdRAAJHXgACR28AAkdxAAJHcwACR3UAAkd3AAJHeQACR3sAAkd9AAJHfwACR5AAAkeTAAJHlgACR5kAAkecAAJHnwACR6IAAkelAAJHqAACR6oAAkfpAAJH6wACR+0AAkfvAAJH8gACR/MAAkf0AAJH9QACR/YAAkf4AAJH+gACR/sAAkf8AAJH/gACR/8AAkg+AAJIQAACSEIAAkhEAAJIRwACSEgAAkhJAAJISgACSEsAAkhNAAJITwACSFAAAkhRAAJIUwACSFQAAkiTAAJIlQACSJgAAkiaAAJInQACSJ4AAkifAAJIoAACSKEAAkijAAJIpQACSKYAAkinAAJIqQACSKoAAki3AAJIuAACSLkAAki7AAJI+gACSPwAAkj+AAJJAAACSQMAAkkEAAJJBQACSQYAAkkHAAJJCQACSQsAAkkMAAJJDQACSQ8AAkkQAAJJTwACSVEAAklTAAJJVQACSVgAAklZAAJJWgACSVsAAklcAAJJXgACSWAAAklhAAJJYgACSWQAAkllAAJJpAACSaYAAkmoAAJJqgACSa0AAkmuAAJJrwACSbAAAkmxAAJJswACSbUAAkm2AAJJtwACSbkAAkm6AAJJ+QACSfsAAkn9AAJJ/wACSgIAAkoDAAJKBAACSgUAAkoGAAJKCAACSgoAAkoLAAJKDAACSg4AAkoPAAJKTgACSlAAAkpSAAJKVAACSlcAAkpYAAJKWQACSloAAkpbAAJKXQACSl8AAkpgAAJKYQACSmMAAkpkAAJKiQACSq0AAkrUAAJK+AACSvsAAkr9AAJK/wACSwEAAksDAAJLBQACSwYAAksJAAJLFgACSyUAAksnAAJLKQACSysAAkstAAJLLwACSzEAAkszAAJLQgACS0UAAktIAAJLSwACS04AAktRAAJLVAACS1cAAktZAAJLmAACS5oAAkucAAJLngACS6EAAkuiAAJLowACS6QAAkulAAJLpwACS6kAAkuqAAJLqwACS60AAkuuAAJL7QACS+8AAkvxAAJL8wACS/YAAkv3AAJL+AACS/kAAkv6AAJL/AACS/4AAkv/AAJMAAACTAIAAkwDAAJMQgACTEQAAkxGAAJMSAACTEsAAkxMAAJMTQACTE4AAkxPAAJMUQACTFMAAkxUAAJMVQACTFcAAkxYAAJMlwACTJkAAkybAAJMnQACTKAAAkyhAAJMogACTKMAAkykAAJMpgACTKgAAkypAAJMqgACTKwAAkytAAJM7AACTO4AAkzwAAJM8gACTPUAAkz2AAJM9wACTPgAAkz5AAJM+wACTP0AAkz+AAJM/wACTQEAAk0CAAJNQQACTUMAAk1FAAJNRwACTUoAAk1LAAJNTAACTU0AAk1OAAJNUAACTVIAAk1TAAJNVAACTVYAAk1XAAJNlgACTZgAAk2bAAJNnQACTaAAAk2hAAJNogACTaMAAk2kAAJNpgACTagAAk2pAAJNqgACTawAAk2tAAJNtgACTbcAAk25AAJN+AACTfoAAk38AAJN/gACTgEAAk4CAAJOAwACTgQAAk4FAAJOBwACTgkAAk4KAAJOCwACTg0AAk4OAAJOTQACTk8AAk5RAAJOUwACTlYAAk5XAAJOWAACTlkAAk5aAAJOXAACTl4AAk5fAAJOYAACTmIAAk5jAAJOogACTqQAAk6nAAJOqQACTqwAAk6tAAJOrgACTq8AAk6wAAJOsgACTrQAAk61AAJOtgACTrgAAk65AAJPBAACTycAAk9HAAJPZwACT2kAAk9rAAJPbQACT28AAk9xAAJPcgACT3MAAk92AAJPdwACT3kAAk96AAJPfAACT34AAk9/AAJPgAACT4MAAk+EAAJPiQACT5YAAk+bAAJPnQACT58AAk+kAAJPpwACT6oAAk+sAAJP0QACT/UAAlAcAAJQQAACUEMAAlBFAAJQRwACUEkAAlBLAAJQTQACUE4AAlBRAAJQXgACUG8AAlBxAAJQcwACUHUAAlB3AAJQeQACUHsAAlB9AAJQfwACUJAAAlCTAAJQlgACUJkAAlCcAAJQnwACUKIAAlClAAJQqAACUKoAAlDpAAJQ6wACUO0AAlDvAAJQ8gACUPMAAlD0AAJQ9QACUPYAAlD4AAJQ+gACUPsAAlD8AAJQ/gACUP8AAlE+AAJRQAACUUIAAlFEAAJRRwACUUgAAlFJAAJRSgACUUsAAlFNAAJRTwACUVAAAlFRAAJRUwACUVQAAlGTAAJRlQACUZgAAlGaAAJRnQACUZ4AAlGfAAJRoAACUaEAAlGjAAJRpQACUaYAAlGnAAJRqQACUaoAAlG3AAJRuAACUbkAAlG7AAJR+gACUfwAAlH+AAJSAAACUgMAAlIEAAJSBQACUgYAAlIHAAJSCQACUgsAAlIMAAJSDQACUg8AAlIQAAJSTwACUlEAAlJTAAJSVQACUlgAAlJZAAJSWgACUlsAAlJcAAJSXgACUmAAAlJhAAJSYgACUmQAAlJlAAJSpAACUqYAAlKoAAJSqgACUq0AAlKuAAJSrwACUrAAAlKxAAJSswACUrUAAlK2AAJStwACUrkAAlK6AAJS+QACUvsAAlL9AAJS/wACUwIAAlMDAAJTBAACUwUAAlMGAAJTCAACUwoAAlMLAAJTDAACUw4AAlMPAAJTTgACU1AAAlNSAAJTVAACU1cAAlNYAAJTWQACU1oAAlNbAAJTXQACU18AAlNgAAJTYQACU2MAAlNkAAJTiQACU60AAlPUAAJT+AACU/sAAlP9AAJT/wACVAEAAlQDAAJUBQACVAYAAlQJAAJUFgACVCUAAlQnAAJUKQACVCsAAlQtAAJULwACVDEAAlQzAAJUQgACVEUAAlRIAAJUSwACVE4AAlRRAAJUVAACVFcAAlRZAAJUmAACVJoAAlScAAJUngACVKEAAlSiAAJUowACVKQAAlSlAAJUpwACVKkAAlSqAAJUqwACVK0AAlSuAAJU7QACVO8AAlTxAAJU8wACVPYAAlT3AAJU+AACVPkAAlT6AAJU/AACVP4AAlT/AAJVAAACVQIAAlUDAAJVQgACVUQAAlVGAAJVSAACVUsAAlVMAAJVTQACVU4AAlVPAAJVUQACVVMAAlVUAAJVVQACVVcAAlVYAAJVlwACVZkAAlWcAAJVngACVaEAAlWiAAJVowACVaQAAlWlAAJVpwACVakAAlWqAAJVqwACVa0AAlWuAAJV8QACVhUAAlY5AAJWXAACVoMAAlajAAJWygACVvEAAlcRAAJXNQACV1kAAldbAAJXXgACV2AAAldiAAJXZAACV2cAAldqAAJXbAACV24AAldxAAJXcwACV3UAAld4AAJXewACV3wAAleBAAJXjgACV5EAAleTAAJXlgACV5kAAlebAAJXwAACV+QAAlgLAAJYLwACWDIAAlg0AAJYNgACWDgAAlg6AAJYPAACWD0AAlhAAAJYTQACWGAAAlhiAAJYZAACWGYAAlhoAAJYagACWGwAAlhuAAJYcAACWHIAAliFAAJYiAACWIsAAliOAAJYkQACWJQAAliXAAJYmgACWJ0AAligAAJYogACWOEAAljjAAJY5gACWOgAAljrAAJY7AACWO0AAljuAAJY7wACWPEAAljzAAJY9AACWPUAAlj3AAJY+AACWQEAAlkCAAJZBAACWUMAAllFAAJZRwACWUkAAllMAAJZTQACWU4AAllPAAJZUAACWVIAAllUAAJZVQACWVYAAllYAAJZWQACWZgAAlmaAAJZnQACWZ8AAlmiAAJZowACWaQAAlmlAAJZpgACWagAAlmqAAJZqwACWawAAlmuAAJZrwACWbgAAlm5AAJZuwACWfoAAln8AAJZ/gACWgAAAloDAAJaBAACWgUAAloGAAJaBwACWgkAAloLAAJaDAACWg0AAloPAAJaEAACWk8AAlpRAAJaVAACWlYAAlpZAAJaWgACWlsAAlpcAAJaXQACWl8AAlphAAJaYgACWmMAAlplAAJaZgACWm8AAlpwAAJacgACWrEAAlqzAAJatQACWrcAAlq6AAJauwACWrwAAlq9AAJavgACWsAAAlrCAAJawwACWsQAAlrGAAJaxwACWwYAAlsIAAJbCwACWw0AAlsQAAJbEQACWxIAAlsTAAJbFAACWxYAAlsYAAJbGQACWxoAAlscAAJbHQACWyoAAlsrAAJbLAACWy4AAlttAAJbbwACW3EAAltzAAJbdgACW3cAAlt4AAJbeQACW3oAAlt8AAJbfgACW38AAluAAAJbggACW4MAAlvCAAJbxAACW8YAAlvIAAJbywACW8wAAlvNAAJbzgACW88AAlvRAAJb0wACW9QAAlvVAAJb1wACW9gAAlvlAAJb7gACW/EAAlvzAAJb9gACW/kAAlwCAAJcBQACXAgAAlwLAAJcDgACXBAAAlwVAAJcGgACXB8AAlxqAAJcjQACXK0AAlzNAAJczwACXNEAAlzTAAJc1QACXNgAAlzZAAJc2gACXN0AAlzeAAJc4AACXOEAAlzjAAJc5gACXOcAAlzoAAJc6wACXOwAAlzxAAJc/gACXQMAAl0FAAJdBwACXQwAAl0PAAJdEgACXRQAAl05AAJdXQACXYQAAl2oAAJdqwACXa0AAl2vAAJdsQACXbMAAl21AAJdtgACXbkAAl3GAAJd1wACXdkAAl3bAAJd3QACXd8AAl3hAAJd4wACXeUAAl3nAAJd+AACXfsAAl3+AAJeAQACXgQAAl4HAAJeCgACXg0AAl4QAAJeEgACXlEAAl5TAAJeVQACXlcAAl5aAAJeWwACXlwAAl5dAAJeXgACXmAAAl5iAAJeYwACXmQAAl5mAAJeZwACXqYAAl6oAAJeqgACXqwAAl6vAAJesAACXrEAAl6yAAJeswACXrUAAl63AAJeuAACXrkAAl67AAJevAACXvsAAl79AAJfAAACXwIAAl8FAAJfBgACXwcAAl8IAAJfCQACXwsAAl8NAAJfDgACXw8AAl8RAAJfEgACXx8AAl8gAAJfIQACXyMAAl9iAAJfZAACX2YAAl9oAAJfawACX2wAAl9tAAJfbgACX28AAl9xAAJfcwACX3QAAl91AAJfdwACX3gAAl+3AAJfuQACX7sAAl+9AAJfwAACX8EAAl/CAAJfwwACX8QAAl/GAAJfyAACX8kAAl/KAAJfzAACX80AAmAMAAJgDgACYBAAAmASAAJgFQACYBYAAmAXAAJgGAACYBkAAmAbAAJgHQACYB4AAmAfAAJgIQACYCIAAmBhAAJgYwACYGUAAmBnAAJgagACYGsAAmBsAAJgbQACYG4AAmBwAAJgcgACYHMAAmB0AAJgdgACYHcAAmC2AAJguAACYLoAAmC8AAJgvwACYMAAAmDBAAJgwgACYMMAAmDFAAJgxwACYMgAAmDJAAJgywACYMwAAmDxAAJhFQACYTwAAmFgAAJhYwACYWUAAmFnAAJhaQACYWsAAmFtAAJhbgACYXEAAmF+AAJhjQACYY8AAmGRAAJhkwACYZUAAmGXAAJhmQACYZsAAmGqAAJhrQACYbAAAmGzAAJhtgACYbkAAmG8AAJhvwACYcEAAmIAAAJiAgACYgQAAmIGAAJiCQACYgoAAmILAAJiDAACYg0AAmIPAAJiEQACYhIAAmITAAJiFQACYhYAAmJVAAJiVwACYlkAAmJbAAJiXgACYl8AAmJgAAJiYQACYmIAAmJkAAJiZgACYmcAAmJoAAJiagACYmsAAmKqAAJirAACYq4AAmKwAAJiswACYrQAAmK1AAJitgACYrcAAmK5AAJiuwACYrwAAmK9AAJivwACYsAAAmL/AAJjAQACYwMAAmMFAAJjCAACYwkAAmMKAAJjCwACYwwAAmMOAAJjEAACYxEAAmMSAAJjFAACYxUAAmNUAAJjVgACY1gAAmNaAAJjXQACY14AAmNfAAJjYAACY2EAAmNjAAJjZQACY2YAAmNnAAJjaQACY2oAAmOpAAJjqwACY60AAmOvAAJjsgACY7MAAmO0AAJjtQACY7YAAmO4AAJjugACY7sAAmO8AAJjvgACY78AAmP+AAJkAAACZAMAAmQFAAJkCAACZAkAAmQKAAJkCwACZAwAAmQOAAJkEAACZBEAAmQSAAJkFAACZBUAAmRgAAJkgwACZKMAAmTDAAJkxQACZMcAAmTJAAJkywACZM4AAmTPAAJk0AACZNMAAmTUAAJk1gACZNcAAmTZAAJk2wACZNwAAmTdAAJk4AACZOEAAmTqAAJk9wACZPwAAmT+AAJlAAACZQUAAmUIAAJlCwACZQ0AAmUyAAJlVgACZX0AAmWhAAJlpAACZaYAAmWoAAJlqgACZawAAmWuAAJlrwACZbIAAmW/AAJl0AACZdIAAmXUAAJl1gACZdgAAmXaAAJl3AACZd4AAmXgAAJl8QACZfQAAmX3AAJl+gACZf0AAmYAAAJmAwACZgYAAmYJAAJmCwACZkoAAmZMAAJmTgACZlAAAmZTAAJmVAACZlUAAmZWAAJmVwACZlkAAmZbAAJmXAACZl0AAmZfAAJmYAACZp8AAmahAAJmowACZqUAAmaoAAJmqQACZqoAAmarAAJmrAACZq4AAmawAAJmsQACZrIAAma0AAJmtQACZvQAAmb2AAJm+QACZvsAAmb+AAJm/wACZwAAAmcBAAJnAgACZwQAAmcGAAJnBwACZwgAAmcKAAJnCwACZxgAAmcZAAJnGgACZxwAAmdbAAJnXQACZ18AAmdhAAJnZAACZ2UAAmdmAAJnZwACZ2gAAmdqAAJnbAACZ20AAmduAAJncAACZ3EAAmewAAJnsgACZ7QAAme2AAJnuQACZ7oAAme7AAJnvAACZ70AAme/AAJnwQACZ8IAAmfDAAJnxQACZ8YAAmgFAAJoBwACaAkAAmgLAAJoDgACaA8AAmgQAAJoEQACaBIAAmgUAAJoFgACaBcAAmgYAAJoGgACaBsAAmhaAAJoXAACaF4AAmhgAAJoYwACaGQAAmhlAAJoZgACaGcAAmhpAAJoawACaGwAAmhtAAJobwACaHAAAmivAAJosQACaLMAAmi1AAJouAACaLkAAmi6AAJouwACaLwAAmi+AAJowAACaMEAAmjCAAJoxAACaMUAAmjqAAJpDgACaTUAAmlZAAJpXAACaV4AAmlgAAJpYgACaWQAAmlmAAJpZwACaWoAAml3AAJphgACaYgAAmmKAAJpjAACaY4AAmmQAAJpkgACaZQAAmmjAAJppgACaakAAmmsAAJprwACabIAAmm1AAJpuAACaboAAmn5AAJp+wACaf4AAmoAAAJqAwACagQAAmoFAAJqBgACagcAAmoJAAJqCwACagwAAmoNAAJqDwACahAAAmpPAAJqUQACalMAAmpVAAJqWAACalkAAmpaAAJqWwACalwAAmpeAAJqYAACamEAAmpiAAJqZAACamUAAmqkAAJqpgACaqgAAmqqAAJqrQACaq4AAmqvAAJqsAACarEAAmqzAAJqtQACarYAAmq3AAJquQACaroAAmr5AAJq+wACav4AAmsAAAJrAwACawQAAmsFAAJrBgACawcAAmsJAAJrCwACawwAAmsNAAJrDwACaxAAAmtPAAJrUQACa1MAAmtVAAJrWAACa1kAAmtaAAJrWwACa1wAAmteAAJrYAACa2EAAmtiAAJrZAACa2UAAmukAAJrpgACa6gAAmuqAAJrrQACa64AAmuvAAJrsAACa7EAAmuzAAJrtQACa7YAAmu3AAJruQACa7oAAmv5AAJr+wACa/0AAmv/AAJsAgACbAMAAmwEAAJsBQACbAYAAmwIAAJsCgACbAsAAmwMAAJsDgACbA8AAmxaAAJsfQACbJ0AAmy9AAJsvwACbMEAAmzDAAJsxQACbMgAAmzJAAJsygACbM0AAmzOAAJs0AACbNEAAmzTAAJs1gACbNcAAmzYAAJs2wACbNwAAmzlAAJs8gACbPcAAmz5AAJs+wACbQAAAm0DAAJtBgACbQgAAm0tAAJtUQACbXgAAm2cAAJtnwACbaEAAm2jAAJtpQACbacAAm2pAAJtqgACba0AAm26AAJtywACbc0AAm3PAAJt0QACbdMAAm3VAAJt1wACbdkAAm3bAAJt7AACbe8AAm3yAAJt9QACbfgAAm37AAJt/gACbgEAAm4EAAJuBgACbkUAAm5HAAJuSQACbksAAm5OAAJuTwACblAAAm5RAAJuUgACblQAAm5WAAJuVwACblgAAm5aAAJuWwACbpoAAm6cAAJungACbqAAAm6jAAJupAACbqUAAm6mAAJupwACbqkAAm6rAAJurAACbq0AAm6vAAJusAACbu8AAm7xAAJu9AACbvYAAm75AAJu+gACbvsAAm78AAJu/QACbv8AAm8BAAJvAgACbwMAAm8FAAJvBgACbxMAAm8UAAJvFQACbxcAAm9WAAJvWAACb1oAAm9cAAJvXwACb2AAAm9hAAJvYgACb2MAAm9lAAJvZwACb2gAAm9pAAJvawACb2wAAm+rAAJvrQACb68AAm+xAAJvtAACb7UAAm+2AAJvtwACb7gAAm+6AAJvvAACb70AAm++AAJvwAACb8EAAnAAAAJwAgACcAQAAnAGAAJwCQACcAoAAnALAAJwDAACcA0AAnAPAAJwEQACcBIAAnATAAJwFQACcBYAAnBVAAJwVwACcFkAAnBbAAJwXgACcF8AAnBgAAJwYQACcGIAAnBkAAJwZgACcGcAAnBoAAJwagACcGsAAnCqAAJwrAACcK4AAnCwAAJwswACcLQAAnC1AAJwtgACcLcAAnC5AAJwuwACcLwAAnC9AAJwvwACcMAAAnDlAAJxCQACcTAAAnFUAAJxVwACcVkAAnFbAAJxXQACcV8AAnFhAAJxYgACcWUAAnFyAAJxgQACcYMAAnGFAAJxhwACcYkAAnGLAAJxjQACcY8AAnGeAAJxoQACcaQAAnGnAAJxqgACca0AAnGwAAJxswACcbUAAnH0AAJx9gACcfkAAnH7AAJx/gACcf8AAnIAAAJyAQACcgIAAnIEAAJyBgACcgcAAnIIAAJyCgACcgsAAnJKAAJyTAACck4AAnJQAAJyUwACclQAAnJVAAJyVgACclcAAnJZAAJyWwACclwAAnJdAAJyXwACcmAAAnKfAAJyoQACcqMAAnKlAAJyqAACcqkAAnKqAAJyqwACcqwAAnKuAAJysAACcrEAAnKyAAJytAACcrUAAnL0AAJy9gACcvkAAnL7AAJy/gACcv8AAnMAAAJzAQACcwIAAnMEAAJzBgACcwcAAnMIAAJzCgACcwsAAnNKAAJzTAACc04AAnNQAAJzUwACc1QAAnNVAAJzVgACc1cAAnNZAAJzWwACc1wAAnNdAAJzXwACc2AAAnOfAAJzoQACc6MAAnOlAAJzqAACc6kAAnOqAAJzqwACc6wAAnOuAAJzsAACc7EAAnOyAAJztAACc7UAAnP0AAJz9gACc/gAAnP6AAJz/QACc/4AAnP/AAJ0AAACdAEAAnQDAAJ0BQACdAYAAnQHAAJ0CQACdAoAAnRVAAJ0eAACdJgAAnS4AAJ0ugACdLwAAnS+AAJ0wAACdMMAAnTEAAJ0xQACdMgAAnTJAAJ0ywACdMwAAnTOAAJ00QACdNIAAnTTAAJ01gACdNcAAnTcAAJ06QACdO4AAnTwAAJ08gACdPcAAnT6AAJ0/QACdP8AAnUkAAJ1SAACdW8AAnWTAAJ1lgACdZgAAnWaAAJ1nAACdZ4AAnWgAAJ1oQACdaQAAnWxAAJ1wgACdcQAAnXGAAJ1yAACdcoAAnXMAAJ1zgACddAAAnXSAAJ14wACdeYAAnXpAAJ17AACde8AAnXyAAJ19QACdfgAAnX7AAJ1/QACdjwAAnY+AAJ2QAACdkIAAnZFAAJ2RgACdkcAAnZIAAJ2SQACdksAAnZNAAJ2TgACdk8AAnZRAAJ2UgACdpEAAnaTAAJ2lQACdpcAAnaaAAJ2mwACdpwAAnadAAJ2ngACdqAAAnaiAAJ2owACdqQAAnamAAJ2pwACduYAAnboAAJ26wACdu0AAnbwAAJ28QACdvIAAnbzAAJ29AACdvYAAnb4AAJ2+QACdvoAAnb8AAJ2/QACdwoAAncLAAJ3DAACdw4AAndNAAJ3TwACd1EAAndTAAJ3VgACd1cAAndYAAJ3WQACd1oAAndcAAJ3XgACd18AAndgAAJ3YgACd2MAAneiAAJ3pAACd6YAAneoAAJ3qwACd6wAAnetAAJ3rgACd68AAnexAAJ3swACd7QAAne1AAJ3twACd7gAAnf3AAJ3+QACd/sAAnf9AAJ4AAACeAEAAngCAAJ4AwACeAQAAngGAAJ4CAACeAkAAngKAAJ4DAACeA0AAnhMAAJ4TgACeFAAAnhSAAJ4VQACeFYAAnhXAAJ4WAACeFkAAnhbAAJ4XQACeF4AAnhfAAJ4YQACeGIAAnihAAJ4owACeKUAAninAAJ4qgACeKsAAnisAAJ4rQACeK4AAniwAAJ4sgACeLMAAni0AAJ4tgACeLcAAnjcAAJ5AAACeScAAnlLAAJ5TgACeVAAAnlSAAJ5VAACeVYAAnlYAAJ5WQACeVwAAnlpAAJ5eAACeXoAAnl8AAJ5fgACeYAAAnmCAAJ5hAACeYYAAnmVAAJ5mAACeZsAAnmeAAJ5oQACeaQAAnmnAAJ5qgACeawAAnnrAAJ57QACee8AAnnxAAJ59AACefUAAnn2AAJ59wACefgAAnn6AAJ5/AACef0AAnn+AAJ6AAACegEAAnpAAAJ6QgACekQAAnpGAAJ6SQACekoAAnpLAAJ6TAACek0AAnpPAAJ6UQACelIAAnpTAAJ6VQACelYAAnqVAAJ6lwACepkAAnqbAAJ6ngACep8AAnqgAAJ6oQACeqIAAnqkAAJ6pgACeqcAAnqoAAJ6qgACeqsAAnrqAAJ67AACeu4AAnrwAAJ68wACevQAAnr1AAJ69gACevcAAnr5AAJ6+wACevwAAnr9AAJ6/wACewAAAns/AAJ7QQACe0MAAntFAAJ7SAACe0kAAntKAAJ7SwACe0wAAntOAAJ7UAACe1EAAntSAAJ7VAACe1UAAnuUAAJ7lgACe5gAAnuaAAJ7nQACe54AAnufAAJ7oAACe6EAAnujAAJ7pQACe6YAAnunAAJ7qQACe6oAAnvpAAJ76wACe+0AAnvvAAJ78gACe/MAAnv0AAJ79QACe/YAAnv4AAJ7+gACe/sAAnv8AAJ7/gACe/8AAnwIAAJ8CQACfAsAAnxKAAJ8TAACfE4AAnxQAAJ8UwACfFQAAnxVAAJ8VgACfFcAAnxZAAJ8WwACfFwAAnxdAAJ8XwACfGAAAnyfAAJ8oQACfKMAAnylAAJ8qAACfKkAAnyqAAJ8qwACfKwAAnyuAAJ8sAACfLEAAnyyAAJ8tAACfLUAAnz0AAJ89gACfPkAAnz7AAJ8/gACfP8AAn0AAAJ9AQACfQIAAn0EAAJ9BgACfQcAAn0IAAJ9CgACfQsAAn1WAAJ9eQACfZkAAn25AAJ9uwACfb0AAn2/AAJ9wQACfcMAAn3EAAJ9xQACfcgAAn3JAAJ9ywACfcwAAn3OAAJ90AACfdEAAn3SAAJ91QACfdYAAn3bAAJ96AACfe0AAn3vAAJ98QACffYAAn35AAJ9/AACff4AAn4jAAJ+RwACfm4AAn6SAAJ+lQACfpcAAn6ZAAJ+mwACfp0AAn6fAAJ+oAACfqMAAn6wAAJ+wQACfsMAAn7FAAJ+xwACfskAAn7LAAJ+zQACfs8AAn7RAAJ+4gACfuUAAn7oAAJ+6wACfu4AAn7xAAJ+9AACfvcAAn76AAJ+/AACfzsAAn89AAJ/PwACf0EAAn9EAAJ/RQACf0YAAn9HAAJ/SAACf0oAAn9MAAJ/TQACf04AAn9QAAJ/UQACf5AAAn+SAAJ/lAACf5YAAn+ZAAJ/mgACf5sAAn+cAAJ/nQACf58AAn+hAAJ/ogACf6MAAn+lAAJ/pgACf+UAAn/nAAJ/6gACf+wAAn/vAAJ/8AACf/EAAn/yAAJ/8wACf/UAAn/3AAJ/+AACf/kAAn/7AAJ//AACgAkAAoAKAAKACwACgA0AAoBMAAKATgACgFAAAoBSAAKAVQACgFYAAoBXAAKAWAACgFkAAoBbAAKAXQACgF4AAoBfAAKAYQACgGIAAoChAAKAowACgKUAAoCnAAKAqgACgKsAAoCsAAKArQACgK4AAoCwAAKAsgACgLMAAoC0AAKAtgACgLcAAoD2AAKA+AACgPoAAoD8AAKA/wACgQAAAoEBAAKBAgACgQMAAoEFAAKBBwACgQgAAoEJAAKBCwACgQwAAoFLAAKBTQACgU8AAoFRAAKBVAACgVUAAoFWAAKBVwACgVgAAoFaAAKBXAACgV0AAoFeAAKBYAACgWEAAoGgAAKBogACgaQAAoGmAAKBqQACgaoAAoGrAAKBrAACga0AAoGvAAKBsQACgbIAAoGzAAKBtQACgbYAAoHbAAKB/wACgiYAAoJKAAKCTQACgk8AAoJRAAKCUwACglUAAoJXAAKCWAACglsAAoJoAAKCdwACgnkAAoJ7AAKCfQACgn8AAoKBAAKCgwACgoUAAoKUAAKClwACgpoAAoKdAAKCoAACgqMAAoKmAAKCqQACgqsAAoLqAAKC7AACgu8AAoLxAAKC9AACgvUAAoL2AAKC9wACgvgAAoL6AAKC/AACgv0AAoL+AAKDAAACgwEAAoNAAAKDQgACg0QAAoNGAAKDSQACg0oAAoNLAAKDTAACg00AAoNPAAKDUQACg1IAAoNTAAKDVQACg1YAAoOVAAKDlwACg5kAAoObAAKDngACg58AAoOgAAKDoQACg6IAAoOkAAKDpgACg6cAAoOoAAKDqgACg6sAAoPqAAKD7AACg+8AAoPxAAKD9AACg/UAAoP2AAKD9wACg/gAAoP6AAKD/AACg/0AAoP+AAKEAAAChAEAAoRAAAKEQgAChEQAAoRGAAKESQAChEoAAoRLAAKETAAChE0AAoRPAAKEUQAChFIAAoRTAAKEVQAChFYAAoSVAAKElwAChJkAAoSbAAKEngAChJ8AAoSgAAKEoQAChKIAAoSkAAKEpgAChKcAAoSoAAKEqgAChKsAAoTqAAKE7AAChO4AAoTwAAKE8wAChPQAAoT1AAKE9gAChPcAAoT5AAKE+wAChPwAAoT9AAKE/wAChQAAAoVLAAKFbgAChY4AAoWuAAKFsAAChbIAAoW0AAKFtgAChbgAAoW5AAKFugAChb0AAoW+AAKFwAAChcEAAoXDAAKFxQAChcYAAoXHAAKFygAChcsAAoXQAAKF3QACheIAAoXkAAKF5gAChesAAoXuAAKF8QAChfMAAoYYAAKGPAAChmMAAoaHAAKGigAChowAAoaOAAKGkAAChpIAAoaUAAKGlQAChpgAAoalAAKGtgAChrgAAoa6AAKGvAAChr4AAobAAAKGwgAChsQAAobGAAKG1wAChtoAAobdAAKG4AAChuMAAobmAAKG6QAChuwAAobvAAKG8QAChzAAAocyAAKHNAAChzYAAoc5AAKHOgAChzsAAoc8AAKHPQAChz8AAodBAAKHQgACh0MAAodFAAKHRgACh4UAAoeHAAKHiQACh4sAAoeOAAKHjwACh5AAAoeRAAKHkgACh5QAAoeWAAKHlwACh5gAAoeaAAKHmwACh9oAAofcAAKH3wACh+EAAofkAAKH5QACh+YAAofnAAKH6AACh+oAAofsAAKH7QACh+4AAofwAAKH8QACh/4AAof/AAKIAAACiAIAAohBAAKIQwACiEUAAohHAAKISgACiEsAAohMAAKITQACiE4AAohQAAKIUgACiFMAAohUAAKIVgACiFcAAoiWAAKImAACiJoAAoicAAKInwACiKAAAoihAAKIogACiKMAAoilAAKIpwACiKgAAoipAAKIqwACiKwAAojrAAKI7QACiO8AAojxAAKI9AACiPUAAoj2AAKI9wACiPgAAoj6AAKI/AACiP0AAoj+AAKJAAACiQEAAolAAAKJQgACiUQAAolGAAKJSQACiUoAAolLAAKJTAACiU0AAolPAAKJUQACiVIAAolTAAKJVQACiVYAAomVAAKJlwACiZkAAombAAKJngACiZ8AAomgAAKJoQACiaIAAomkAAKJpgACiacAAomoAAKJqgACiasAAonQAAKJ9AACihsAAoo/AAKKQgACikQAAopGAAKKSAACikoAAopMAAKKTQACilAAAopdAAKKbAACim4AAopwAAKKcgACinQAAop2AAKKeAACinoAAoqJAAKKjAACio8AAoqSAAKKlQACipgAAoqbAAKKngACiqAAAorfAAKK4QACiuMAAorlAAKK6AACiukAAorqAAKK6wACiuwAAoruAAKK8AACivEAAoryAAKK9AACivUAAos0AAKLNgACizgAAos6AAKLPQACiz4AAos/AAKLQAACi0EAAotDAAKLRQACi0YAAotHAAKLSQACi0oAAouJAAKLiwACi40AAouPAAKLkgACi5MAAouUAAKLlQACi5YAAouYAAKLmgACi5sAAoucAAKLngACi58AAoveAAKL4AACi+IAAovkAAKL5wACi+gAAovpAAKL6gACi+sAAovtAAKL7wACi/AAAovxAAKL8wACi/QAAowzAAKMNQACjDcAAow5AAKMPAACjD0AAow+AAKMPwACjEAAAoxCAAKMRAACjEUAAoxGAAKMSAACjEkAAoyIAAKMigACjIwAAoyOAAKMkQACjJIAAoyTAAKMlAACjJUAAoyXAAKMmQACjJoAAoybAAKMnQACjJ4AAozdAAKM3wACjOEAAozjAAKM5gACjOcAAozoAAKM6QACjOoAAozsAAKM7gACjO8AAozwAAKM8gACjPMAAoz8AAKM/QACjP8AAo0IAAKNDQACjRAAAo0TAAKNFQACjTIAAo08AAKNRQACjUgAAo1KAAKNTQACjVAAAo1TAAKNVQACjZgAAo28AAKN4AACjgMAAo4qAAKOSgACjnEAAo6YAAKOuAACjtwAAo8AAAKPAgACjwUAAo8HAAKPCQACjwsAAo8OAAKPEQACjxMAAo8VAAKPGAACjxoAAo8cAAKPHwACjyIAAo8jAAKPLAACjzkAAo88AAKPPgACj0EAAo9EAAKPRgACj2sAAo+PAAKPtgACj9oAAo/dAAKP3wACj+EAAo/jAAKP5QACj+cAAo/oAAKP6wACj/gAApALAAKQDQACkA8AApARAAKQEwACkBUAApAXAAKQGQACkBsAApAdAAKQMAACkDMAApA2AAKQOQACkDwAApA/AAKQQgACkEUAApBIAAKQSwACkE0AApCMAAKQjgACkJEAApCTAAKQlgACkJcAApCYAAKQmQACkJoAApCcAAKQngACkJ8AApCgAAKQogACkKMAApCsAAKQrQACkK8AApDuAAKQ8AACkPIAApD0AAKQ9wACkPgAApD5AAKQ+gACkPsAApD9AAKQ/wACkQAAApEBAAKRAwACkQQAApFDAAKRRQACkUgAApFKAAKRTQACkU4AApFPAAKRUAACkVEAApFTAAKRVQACkVYAApFXAAKRWQACkVoAApFjAAKRZAACkWYAApGlAAKRpwACkakAApGrAAKRrgACka8AApGwAAKRsQACkbIAApG0AAKRtgACkbcAApG4AAKRugACkbsAApH6AAKR/AACkf8AApIBAAKSBAACkgUAApIGAAKSBwACkggAApIKAAKSDAACkg0AApIOAAKSEAACkhEAApIaAAKSGwACkh0AApJcAAKSXgACkmAAApJiAAKSZQACkmYAApJnAAKSaAACkmkAApJrAAKSbQACkm4AApJvAAKScQACknIAApKxAAKSswACkrYAApK4AAKSuwACkrwAApK9AAKSvgACkr8AApLBAAKSwwACksQAApLFAAKSxwACksgAApLVAAKS1gACktcAApLZAAKTGAACkxoAApMcAAKTHgACkyEAApMiAAKTIwACkyQAApMlAAKTJwACkykAApMqAAKTKwACky0AApMuAAKTbQACk28AApNxAAKTcwACk3YAApN3AAKTeAACk3kAApN6AAKTfAACk34AApN/AAKTgAACk4IAApODAAKTkAACk5MAApOWAAKTmQACk5wAApOeAAKTpwACk/IAApQVAAKUNQAClFUAApRXAAKUWQAClFsAApRdAAKUYAAClGEAApRiAAKUZQAClGYAApRoAAKUaQAClGsAApRuAAKUbwAClHAAApRzAAKUdAAClH0AApSKAAKUjwAClJEAApSTAAKUmAAClJsAApSeAAKUoAAClMUAApTpAAKVEAAClTQAApU3AAKVOQAClTsAApU9AAKVPwAClUEAApVCAAKVRQAClVIAApVjAAKVZQAClWcAApVpAAKVawAClW0AApVvAAKVcQAClXMAApWEAAKVhwAClYoAApWNAAKVkAAClZMAApWWAAKVmQAClZwAApWeAAKV3QACld8AApXhAAKV4wACleYAApXnAAKV6AAClekAApXqAAKV7AACle4AApXvAAKV8AAClfIAApXzAAKWMgACljQAApY2AAKWOAACljsAApY8AAKWPQAClj4AApY/AAKWQQAClkMAApZEAAKWRQAClkcAApZIAAKWhwAClokAApaMAAKWjgAClpEAApaSAAKWkwAClpQAApaVAAKWlwAClpkAApaaAAKWmwAClp0AApaeAAKWqwAClqwAApatAAKWrwAClu4AApbwAAKW8gAClvQAApb3AAKW+AAClvkAApb6AAKW+wAClv0AApb/AAKXAAAClwEAApcDAAKXBAACl0MAApdFAAKXRwACl0kAApdMAAKXTQACl04AApdPAAKXUAACl1IAApdUAAKXVQACl1YAApdYAAKXWQACl5gAApeaAAKXnAACl54AApehAAKXogACl6MAApekAAKXpQACl6cAApepAAKXqgACl6sAApetAAKXrgACl+0AApfvAAKX8QACl/MAApf2AAKX9wACl/gAApf5AAKX+gACl/wAApf+AAKX/wACmAAAApgCAAKYAwACmEIAAphEAAKYRgACmEgAAphLAAKYTAACmE0AAphOAAKYTwACmFEAAphTAAKYVAACmFUAAphXAAKYWAACmH0AApihAAKYyAACmOwAApjvAAKY8QACmPMAApj1AAKY9wACmPkAApj6AAKY/QACmQoAApkZAAKZGwACmR0AApkfAAKZIQACmSMAApklAAKZJwACmTYAApk5AAKZPAACmT8AAplCAAKZRQACmUgAAplLAAKZTQACmYwAApmOAAKZkAACmZIAApmVAAKZlgACmZcAApmYAAKZmQACmZsAApmdAAKZngACmZ8AApmhAAKZogACmeEAApnjAAKZ5QACmecAApnqAAKZ6wACmewAApntAAKZ7gACmfAAApnyAAKZ8wACmfQAApn2AAKZ9wACmjYAApo4AAKaOgACmjwAApo/AAKaQAACmkEAAppCAAKaQwACmkUAAppHAAKaSAACmkkAAppLAAKaTAACmosAApqNAAKakAACmpIAApqVAAKalgACmpcAApqYAAKamQACmpsAApqdAAKangACmp8AApqhAAKaogACmuUAApsJAAKbLQACm1AAApt3AAKblwACm74AApvlAAKcBQACnCkAApxNAAKcTwACnFIAApxUAAKcVgACnFgAApxbAAKcXgACnGAAApxiAAKcZQACnGcAApxpAAKcbAACnG8AApxwAAKcdQACnIIAApyFAAKchwACnIoAApyNAAKcjwACnLQAApzYAAKc/wACnSMAAp0mAAKdKAACnSoAAp0sAAKdLgACnTAAAp0xAAKdNAACnUEAAp1UAAKdVgACnVgAAp1aAAKdXAACnV4AAp1gAAKdYgACnWQAAp1mAAKdeQACnXwAAp1/AAKdggACnYUAAp2IAAKdiwACnY4AAp2RAAKdlAACnZYAAp3VAAKd1wACndoAAp3cAAKd3wACneAAAp3hAAKd4gACneMAAp3lAAKd5wACnegAAp3pAAKd6wACnewAAp31AAKd9gACnfgAAp43AAKeOQACnjsAAp49AAKeQAACnkEAAp5CAAKeQwACnkQAAp5GAAKeSAACnkkAAp5KAAKeTAACnk0AAp6MAAKejgACnpEAAp6TAAKelgACnpcAAp6YAAKemQACnpoAAp6cAAKengACnp8AAp6gAAKeogACnqMAAp6sAAKerQACnq8AAp7uAAKe8AACnvIAAp70AAKe9wACnvgAAp75AAKe+gACnvsAAp79AAKe/wACnwAAAp8BAAKfAwACnwQAAp9DAAKfRQACn0gAAp9KAAKfTQACn04AAp9PAAKfUAACn1EAAp9TAAKfVQACn1YAAp9XAAKfWQACn1oAAp9jAAKfZAACn2YAAp+lAAKfpwACn6kAAp+rAAKfrgACn68AAp+wAAKfsQACn7IAAp+0AAKftgACn7cAAp+4AAKfugACn7sAAp/6AAKf/AACn/8AAqABAAKgBAACoAUAAqAGAAKgBwACoAgAAqAKAAKgDAACoA0AAqAOAAKgEAACoBEAAqAeAAKgHwACoCAAAqAiAAKgYQACoGMAAqBlAAKgZwACoGoAAqBrAAKgbAACoG0AAqBuAAKgcAACoHIAAqBzAAKgdAACoHYAAqB3AAKgtgACoLgAAqC6AAKgvAACoL8AAqDAAAKgwQACoMIAAqDDAAKgxQACoMcAAqDIAAKgyQACoMsAAqDMAAKg2QACoOQAAqDmAAKg6QACoOsAAqDuAAKg8QACoPwAAqD/AAKhAgACoQUAAqEIAAKhCwACoQ0AAqETAAKhGAACoR4AAqFpAAKhjAACoawAAqHMAAKhzgACodAAAqHSAAKh1AACodcAAqHYAAKh2QACodwAAqHdAAKh3wACoeAAAqHiAAKh5AACoeUAAqHmAAKh6QACoeoAAqHvAAKh/AACogEAAqIDAAKiBQACogoAAqINAAKiEAACohIAAqI3AAKiWwACooIAAqKmAAKiqQACoqsAAqKtAAKirwACorEAAqKzAAKitAACorcAAqLEAAKi1QACotcAAqLZAAKi2wACot0AAqLfAAKi4QACouMAAqLlAAKi9gACovkAAqL8AAKi/wACowIAAqMFAAKjCAACowsAAqMOAAKjEAACo08AAqNRAAKjUwACo1UAAqNYAAKjWQACo1oAAqNbAAKjXAACo14AAqNgAAKjYQACo2IAAqNkAAKjZQACo6QAAqOmAAKjqAACo6oAAqOtAAKjrgACo68AAqOwAAKjsQACo7MAAqO1AAKjtgACo7cAAqO5AAKjugACo/kAAqP7AAKj/gACpAAAAqQDAAKkBAACpAUAAqQGAAKkBwACpAkAAqQLAAKkDAACpA0AAqQPAAKkEAACpB0AAqQeAAKkHwACpCEAAqRgAAKkYgACpGQAAqRmAAKkaQACpGoAAqRrAAKkbAACpG0AAqRvAAKkcQACpHIAAqRzAAKkdQACpHYAAqS1AAKktwACpLkAAqS7AAKkvgACpL8AAqTAAAKkwQACpMIAAqTEAAKkxgACpMcAAqTIAAKkygACpMsAAqUKAAKlDAACpQ4AAqUQAAKlEwACpRQAAqUVAAKlFgACpRcAAqUZAAKlGwACpRwAAqUdAAKlHwACpSAAAqVfAAKlYQACpWMAAqVlAAKlaAACpWkAAqVqAAKlawACpWwAAqVuAAKlcAACpXEAAqVyAAKldAACpXUAAqW0AAKltgACpbgAAqW6AAKlvQACpb4AAqW/AAKlwAACpcEAAqXDAAKlxQACpcYAAqXHAAKlyQACpcoAAqXvAAKmEwACpjoAAqZeAAKmYQACpmMAAqZlAAKmZwACpmkAAqZrAAKmbAACpm8AAqZ8AAKmiwACpo0AAqaPAAKmkQACppMAAqaVAAKmlwACppkAAqaoAAKmqwACpq4AAqaxAAKmtAACprcAAqa6AAKmvQACpr8AAqb+AAKnAAACpwMAAqcFAAKnCAACpwkAAqcKAAKnCwACpwwAAqcOAAKnEAACpxEAAqcSAAKnFAACpxUAAqdUAAKnVgACp1gAAqdaAAKnXQACp14AAqdfAAKnYAACp2EAAqdjAAKnZQACp2YAAqdnAAKnaQACp2oAAqepAAKnqwACp60AAqevAAKnsgACp7MAAqe0AAKntQACp7YAAqe4AAKnugACp7sAAqe8AAKnvgACp78AAqf+AAKoAAACqAMAAqgFAAKoCAACqAkAAqgKAAKoCwACqAwAAqgOAAKoEAACqBEAAqgSAAKoFAACqBUAAqhUAAKoVgACqFgAAqhaAAKoXQACqF4AAqhfAAKoYAACqGEAAqhjAAKoZQACqGYAAqhnAAKoaQACqGoAAqipAAKoqwACqK0AAqivAAKosgACqLMAAqi0AAKotQACqLYAAqi4AAKougACqLsAAqi8AAKovgACqL8AAqj+AAKpAAACqQIAAqkEAAKpBwACqQgAAqkJAAKpCgACqQsAAqkNAAKpDwACqRAAAqkRAAKpEwACqRQAAqlfAAKpggACqaIAAqnCAAKpxAACqcYAAqnIAAKpygACqc0AAqnOAAKpzwACqdIAAqnTAAKp1QACqdYAAqnYAAKp2wACqdwAAqndAAKp4AACqeEAAqnmAAKp8wACqfgAAqn6AAKp/AACqgEAAqoEAAKqBwACqgkAAqouAAKqUgACqnkAAqqdAAKqoAACqqIAAqqkAAKqpgACqqgAAqqqAAKqqwACqq4AAqq7AAKqzAACqs4AAqrQAAKq0gACqtQAAqrWAAKq2AACqtoAAqrcAAKq7QACqvAAAqrzAAKq9gACqvkAAqr8AAKq/wACqwIAAqsFAAKrBwACq0YAAqtIAAKrSgACq0wAAqtPAAKrUAACq1EAAqtSAAKrUwACq1UAAqtXAAKrWAACq1kAAqtbAAKrXAACq5sAAqudAAKrnwACq6EAAqukAAKrpQACq6YAAqunAAKrqAACq6oAAqusAAKrrQACq64AAquwAAKrsQACq/AAAqvyAAKr9QACq/cAAqv6AAKr+wACq/wAAqv9AAKr/gACrAAAAqwCAAKsAwACrAQAAqwGAAKsBwACrBQAAqwVAAKsFgACrBgAAqxXAAKsWQACrFsAAqxdAAKsYAACrGEAAqxiAAKsYwACrGQAAqxmAAKsaAACrGkAAqxqAAKsbAACrG0AAqysAAKsrgACrLAAAqyyAAKstQACrLYAAqy3AAKsuAACrLkAAqy7AAKsvQACrL4AAqy/AAKswQACrMIAAq0BAAKtAwACrQUAAq0HAAKtCgACrQsAAq0MAAKtDQACrQ4AAq0QAAKtEgACrRMAAq0UAAKtFgACrRcAAq1WAAKtWAACrVoAAq1cAAKtXwACrWAAAq1hAAKtYgACrWMAAq1lAAKtZwACrWgAAq1pAAKtawACrWwAAq2rAAKtrQACra8AAq2xAAKttAACrbUAAq22AAKttwACrbgAAq26AAKtvAACrb0AAq2+AAKtwAACrcEAAq3mAAKuCgACrjEAAq5VAAKuWAACrloAAq5cAAKuXgACrmAAAq5iAAKuYwACrmYAAq5zAAKuggACroQAAq6GAAKuiAACrooAAq6MAAKujgACrpAAAq6fAAKuogACrqUAAq6oAAKuqwACrq4AAq6xAAKutAACrrYAAq71AAKu9wACrvoAAq78AAKu/wACrwAAAq8BAAKvAgACrwMAAq8FAAKvBwACrwgAAq8JAAKvCwACrwwAAq9LAAKvTQACr08AAq9RAAKvVAACr1UAAq9WAAKvVwACr1gAAq9aAAKvXAACr10AAq9eAAKvYAACr2EAAq+gAAKvogACr6QAAq+mAAKvqQACr6oAAq+rAAKvrAACr60AAq+vAAKvsQACr7IAAq+zAAKvtQACr7YAAq/1AAKv9wACr/oAAq/8AAKv/wACsAAAArABAAKwAgACsAMAArAFAAKwBwACsAgAArAJAAKwCwACsAwAArBLAAKwTQACsE8AArBRAAKwVAACsFUAArBWAAKwVwACsFgAArBaAAKwXAACsF0AArBeAAKwYAACsGEAArCgAAKwogACsKQAArCmAAKwqQACsKoAArCrAAKwrAACsK0AArCvAAKwsQACsLIAArCzAAKwtQACsLYAArD1AAKw9wACsPkAArD7AAKw/gACsP8AArEAAAKxAQACsQIAArEEAAKxBgACsQcAArEIAAKxCgACsQsAArFWAAKxeQACsZkAArG5AAKxuwACsb0AArG/AAKxwQACscQAArHFAAKxxgACsckAArHKAAKxzAACsc0AArHPAAKx0QACsdIAArHTAAKx1gACsdcAArHgAAKx7QACsfIAArH0AAKx9gACsfsAArH+AAKyAQACsgMAArIoAAKyTAACsnMAArKXAAKymgACspwAArKeAAKyoAACsqIAArKkAAKypQACsqgAArK1AAKyxgACssgAArLKAAKyzAACss4AArLQAAKy0gACstQAArLWAAKy5wACsuoAArLtAAKy8AACsvMAArL2AAKy+QACsvwAArL/AAKzAQACs0AAArNCAAKzRAACs0YAArNJAAKzSgACs0sAArNMAAKzTQACs08AArNRAAKzUgACs1MAArNVAAKzVgACs5UAArOXAAKzmQACs5sAArOeAAKznwACs6AAArOhAAKzogACs6QAArOmAAKzpwACs6gAArOqAAKzqwACs+oAArPsAAKz7wACs/EAArP0AAKz9QACs/YAArP3AAKz+AACs/oAArP8AAKz/QACs/4AArQAAAK0AQACtA4AArQPAAK0EAACtBIAArRRAAK0UwACtFUAArRXAAK0WgACtFsAArRcAAK0XQACtF4AArRgAAK0YgACtGMAArRkAAK0ZgACtGcAArSmAAK0qAACtKoAArSsAAK0rwACtLAAArSxAAK0sgACtLMAArS1AAK0twACtLgAArS5AAK0uwACtLwAArT7AAK0/QACtP8AArUBAAK1BAACtQUAArUGAAK1BwACtQgAArUKAAK1DAACtQ0AArUOAAK1EAACtREAArVQAAK1UgACtVQAArVWAAK1WQACtVoAArVbAAK1XAACtV0AArVfAAK1YQACtWIAArVjAAK1ZQACtWYAArWlAAK1pwACtakAArWrAAK1rgACta8AArWwAAK1sQACtbIAArW0AAK1tgACtbcAArW4AAK1ugACtbsAArXgAAK2BAACtisAArZPAAK2UgACtlQAArZWAAK2WAACtloAArZcAAK2XQACtmAAArZtAAK2fAACtn4AAraAAAK2ggACtoQAAraGAAK2iAACtooAAraZAAK2nAACtp8AAraiAAK2pQACtqgAArarAAK2rgACtrAAArbvAAK28QACtvMAArb1AAK2+AACtvkAArb6AAK2+wACtvwAArb+AAK3AAACtwEAArcCAAK3BAACtwUAArdEAAK3RgACt0gAArdKAAK3TQACt04AArdPAAK3UAACt1EAArdTAAK3VQACt1YAArdXAAK3WQACt1oAAreZAAK3mwACt50AArefAAK3ogACt6MAArekAAK3pQACt6YAAreoAAK3qgACt6sAAresAAK3rgACt68AArfuAAK38AACt/IAArf0AAK39wACt/gAArf5AAK3+gACt/sAArf9AAK3/wACuAAAArgBAAK4AwACuAQAArhDAAK4RQACuEcAArhJAAK4TAACuE0AArhOAAK4TwACuFAAArhSAAK4VAACuFUAArhWAAK4WAACuFkAAriYAAK4mgACuJwAArieAAK4oQACuKIAArijAAK4pAACuKUAArinAAK4qQACuKoAArirAAK4rQACuK4AArjtAAK47wACuPEAArjzAAK49gACuPcAArj4AAK4+QACuPoAArj8AAK4/gACuP8AArkAAAK5AgACuQMAArlOAAK5cQACuZEAArmxAAK5swACubUAArm3AAK5uQACubwAArm9AAK5vgACucEAArnCAAK5xAACucUAArnHAAK5ygACucsAArnMAAK5zwACudAAArnVAAK54gACuecAArnpAAK56wACufAAArnzAAK59gACufgAArodAAK6QQACumgAArqMAAK6jwACupEAArqTAAK6lQACupcAArqZAAK6mgACup0AArqqAAK6uwACur0AArq/AAK6wQACusMAArrFAAK6xwACuskAArrLAAK63AACut8AArriAAK65QACuugAArrrAAK67gACuvEAArr0AAK69gACuzUAArs3AAK7OQACuzsAArs+AAK7PwACu0AAArtBAAK7QgACu0QAArtGAAK7RwACu0gAArtKAAK7SwACu4oAAruMAAK7jgACu5AAAruTAAK7lAACu5UAAruWAAK7lwACu5kAArubAAK7nAACu50AArufAAK7oAACu98AArvhAAK75AACu+YAArvpAAK76gACu+sAArvsAAK77QACu+8AArvxAAK78gACu/MAArv1AAK79gACvAMAArwEAAK8BQACvAcAArxGAAK8SAACvEoAArxMAAK8TwACvFAAArxRAAK8UgACvFMAArxVAAK8VwACvFgAArxZAAK8WwACvFwAArybAAK8nQACvJ8AAryhAAK8pAACvKUAArymAAK8pwACvKgAAryqAAK8rAACvK0AAryuAAK8sAACvLEAArzwAAK88gACvPQAArz2AAK8+QACvPoAArz7AAK8/AACvP0AArz/AAK9AQACvQIAAr0DAAK9BQACvQYAAr1FAAK9RwACvUkAAr1LAAK9TgACvU8AAr1QAAK9UQACvVIAAr1UAAK9VgACvVcAAr1YAAK9WgACvVsAAr2aAAK9nAACvZ4AAr2gAAK9owACvaQAAr2lAAK9pgACvacAAr2pAAK9qwACvawAAr2tAAK9rwACvbAAAr3VAAK9+QACviAAAr5EAAK+RwACvkkAAr5LAAK+TQACvk8AAr5RAAK+UgACvlUAAr5iAAK+cQACvnMAAr51AAK+dwACvnkAAr57AAK+fQACvn8AAr6OAAK+kQACvpQAAr6XAAK+mgACvp0AAr6gAAK+owACvqUAAr7kAAK+5gACvugAAr7qAAK+7QACvu4AAr7vAAK+8AACvvEAAr7zAAK+9QACvvYAAr73AAK++QACvvoAAr85AAK/OwACvz0AAr8/AAK/QgACv0MAAr9EAAK/RQACv0YAAr9IAAK/SgACv0sAAr9MAAK/TgACv08AAr+OAAK/kAACv5IAAr+UAAK/lwACv5gAAr+ZAAK/mgACv5sAAr+dAAK/nwACv6AAAr+hAAK/owACv6QAAr/jAAK/5QACv+gAAr/qAAK/7QACv+4AAr/vAAK/8AACv/EAAr/zAAK/9QACv/YAAr/3AAK/+QACv/oAAsA5AALAOwACwD0AAsA/AALAQgACwEMAAsBEAALARQACwEYAAsBIAALASgACwEsAAsBMAALATgACwE8AAsCOAALAkAACwJIAAsCUAALAlwACwJgAAsCZAALAmgACwJsAAsCdAALAnwACwKAAAsChAALAowACwKQAAsDjAALA5QACwOgAAsDqAALA7QACwO4AAsDvAALA8AACwPEAAsDzAALA9QACwPYAAsD3AALA+QACwPoAAsFFAALBaAACwYgAAsGoAALBqgACwawAAsGuAALBsAACwbMAAsG0AALBtQACwbgAAsG5AALBuwACwbwAAsG+AALBwQACwcIAAsHDAALBxgACwccAAsHMAALB2QACwd4AAsHgAALB4gACwecAAsHqAALB7QACwe8AAsIUAALCOAACwl8AAsKDAALChgACwogAAsKKAALCjAACwo4AAsKQAALCkQACwpQAAsKhAALCsgACwrQAAsK2AALCuAACwroAAsK8AALCvgACwsAAAsLCAALC0wACwtYAAsLZAALC3AACwt8AAsLiAALC5QACwugAAsLrAALC7QACwywAAsMuAALDMAACwzIAAsM1AALDNgACwzcAAsM4AALDOQACwzsAAsM9AALDPgACwz8AAsNBAALDQgACw4EAAsODAALDhQACw4cAAsOKAALDiwACw4wAAsONAALDjgACw5AAAsOSAALDkwACw5QAAsOWAALDlwACw9YAAsPYAALD2wACw90AAsPgAALD4QACw+IAAsPjAALD5AACw+YAAsPoAALD6QACw+oAAsPsAALD7QACw/oAAsP7AALD/AACw/4AAsQ9AALEPwACxEEAAsRDAALERgACxEcAAsRIAALESQACxEoAAsRMAALETgACxE8AAsRQAALEUgACxFMAAsSSAALElAACxJYAAsSYAALEmwACxJwAAsSdAALEngACxJ8AAsShAALEowACxKQAAsSlAALEpwACxKgAAsTnAALE6QACxOsAAsTtAALE8AACxPEAAsTyAALE8wACxPQAAsT2AALE+AACxPkAAsT6AALE/AACxP0AAsU8AALFPgACxUAAAsVCAALFRQACxUYAAsVHAALFSAACxUkAAsVLAALFTQACxU4AAsVPAALFUQACxVIAAsWRAALFkwACxZUAAsWXAALFmgACxZsAAsWcAALFnQACxZ4AAsWgAALFogACxaMAAsWkAALFpgACxacAAsXMAALF8AACxhcAAsY7AALGPgACxkAAAsZCAALGRAACxkYAAsZIAALGSQACxkwAAsZZAALGaAACxmoAAsZsAALGbgACxnAAAsZyAALGdAACxnYAAsaFAALGiAACxosAAsaOAALGkQACxpQAAsaXAALGmgACxpwAAsbbAALG3QACxuAAAsbiAALG5QACxuYAAsbnAALG6AACxukAAsbrAALG7QACxu4AAsbvAALG8QACxvIAAscxAALHMwACxzUAAsc3AALHOgACxzsAAsc8AALHPQACxz4AAsdAAALHQgACx0MAAsdEAALHRgACx0cAAseGAALHiAACx4oAAseMAALHjwACx5AAAseRAALHkgACx5MAAseVAALHlwACx5gAAseZAALHmwACx5wAAsfbAALH3QACx+AAAsfiAALH5QACx+YAAsfnAALH6AACx+kAAsfrAALH7QACx+4AAsfvAALH8QACx/IAAsgxAALIMwACyDUAAsg3AALIOgACyDsAAsg8AALIPQACyD4AAshAAALIQgACyEMAAshEAALIRgACyEcAAsiGAALIiAACyIoAAsiMAALIjwACyJAAAsiRAALIkgACyJMAAsiVAALIlwACyJgAAsiZAALImwACyJwAAsjbAALI3QACyN8AAsjhAALI5AACyOUAAsjmAALI5wACyOgAAsjqAALI7AACyO0AAsjuAALI8AACyPEAAsj6AALI+wACyP0AAsk8AALJPgACyUAAAslCAALJRQACyUYAAslHAALJSAACyUkAAslLAALJTQACyU4AAslPAALJUQACyVIAAsmRAALJkwACyZUAAsmXAALJmgACyZsAAsmcAALJnQACyZ4AAsmgAALJogACyaMAAsmkAALJpgACyacAAsnmAALJ6AACyesAAsntAALJ8AACyfEAAsnyAALJ8wACyfQAAsn2AALJ+AACyfkAAsn6AALJ/AACyf0AAsoGAALKCQACygwAAsoOAALKEwACyhwAAso1AALKPgACylcAAspuAALKgAACyp0AAsqgAALKogACyqQAAsqmAALKqQACyqsAAsqwAALK7wACyvEAAsrzAALK9QACyvcAAsr4AALK+QACyvoAAsr7AALK/QACyv8AAssAAALLAQACywMAAssEAALLQwACy0UAAstHAALLSQACy0sAAstMAALLTQACy04AAstPAALLUQACy1MAAstUAALLVQACy1cAAstYAALLlwACy5kAAsubAALLnQACy58AAsugAALLoQACy6IAAsujAALLpQACy6cAAsuoAALLqQACy6sAAsusAALLtQACy7gAAsu7AALLvQACzAAAAswkAALMSAACzGsAAsySAALMsgACzNkAAs0AAALNIAACzUQAAs1oAALNagACzW0AAs1vAALNcQACzXMAAs12AALNeQACzXsAAs19AALNgAACzYIAAs2EAALNhwACzYoAAs2LAALNkAACzZ0AAs2gAALNogACzaUAAs2oAALNqgACzc8AAs3zAALOGgACzj4AAs5BAALOQwACzkUAAs5HAALOSQACzksAAs5MAALOTwACzlwAAs5vAALOcQACznMAAs51AALOdwACznkAAs57AALOfQACzn8AAs6BAALOlAACzpcAAs6aAALOnQACzqAAAs6jAALOpgACzqkAAs6sAALOrwACzrEAAs7wAALO8gACzvUAAs73AALO+gACzvsAAs78AALO/QACzv4AAs8AAALPAgACzwMAAs8EAALPBgACzwcAAs8QAALPEQACzxMAAs9SAALPVAACz1YAAs9YAALPWwACz1wAAs9dAALPXgACz18AAs9hAALPYwACz2QAAs9lAALPZwACz2gAAs+nAALPqQACz6wAAs+uAALPsQACz7IAAs+zAALPtAACz7UAAs+3AALPuQACz7oAAs+7AALPvQACz74AAs/HAALPyAACz8oAAtAJAALQCwAC0A0AAtAPAALQEgAC0BMAAtAUAALQFQAC0BYAAtAYAALQGgAC0BsAAtAcAALQHgAC0B8AAtBeAALQYAAC0GMAAtBlAALQaAAC0GkAAtBqAALQawAC0GwAAtBuAALQcAAC0HEAAtByAALQdAAC0HUAAtB+AALQfwAC0IEAAtDAAALQwgAC0MQAAtDGAALQyQAC0MoAAtDLAALQzAAC0M0AAtDPAALQ0QAC0NIAAtDTAALQ1QAC0NYAAtEVAALRFwAC0RoAAtEcAALRHwAC0SAAAtEhAALRIgAC0SMAAtElAALRJwAC0SgAAtEpAALRKwAC0SwAAtE5AALROgAC0TsAAtE9AALRfAAC0X4AAtGAAALRggAC0YUAAtGGAALRhwAC0YgAAtGJAALRiwAC0Y0AAtGOAALRjwAC0ZEAAtGSAALR0QAC0dMAAtHVAALR1wAC0doAAtHbAALR3AAC0d0AAtHeAALR4AAC0eIAAtHjAALR5AAC0eYAAtHnAALR9AAC0f0AAtIAAALSAwAC0gUAAtIIAALSEQAC0hQAAtIXAALSGgAC0h0AAtIfAALSJQAC0jcAAtJEAALSjwAC0rIAAtLSAALS8gAC0vQAAtL2AALS+AAC0voAAtL9AALS/gAC0v8AAtMCAALTAwAC0wUAAtMGAALTCAAC0wsAAtMMAALTDQAC0xAAAtMRAALTFgAC0yMAAtMoAALTKgAC0ywAAtMxAALTNAAC0zcAAtM5AALTXgAC04IAAtOpAALTzQAC09AAAtPSAALT1AAC09YAAtPYAALT2gAC09sAAtPeAALT6wAC0/wAAtP+AALUAAAC1AIAAtQEAALUBgAC1AgAAtQKAALUDAAC1B0AAtQgAALUIwAC1CYAAtQpAALULAAC1C8AAtQyAALUNQAC1DcAAtR2AALUeAAC1HoAAtR8AALUfwAC1IAAAtSBAALUggAC1IMAAtSFAALUhwAC1IgAAtSJAALUiwAC1IwAAtTLAALUzQAC1M8AAtTRAALU1AAC1NUAAtTWAALU1wAC1NgAAtTaAALU3AAC1N0AAtTeAALU4AAC1OEAAtUgAALVIgAC1SUAAtUnAALVKgAC1SsAAtUsAALVLQAC1S4AAtUwAALVMgAC1TMAAtU0AALVNgAC1TcAAtVEAALVRQAC1UYAAtVIAALVhwAC1YkAAtWLAALVjQAC1ZAAAtWRAALVkgAC1ZMAAtWUAALVlgAC1ZgAAtWZAALVmgAC1ZwAAtWdAALV3AAC1d4AAtXgAALV4gAC1eUAAtXmAALV5wAC1egAAtXpAALV6wAC1e0AAtXuAALV7wAC1fEAAtXyAALWMQAC1jMAAtY1AALWNwAC1joAAtY7AALWPAAC1j0AAtY+AALWQAAC1kIAAtZDAALWRAAC1kYAAtZHAALWhgAC1ogAAtaKAALWjAAC1o8AAtaQAALWkQAC1pIAAtaTAALWlQAC1pcAAtaYAALWmQAC1psAAtacAALW2wAC1t0AAtbfAALW4QAC1uQAAtblAALW5gAC1ucAAtboAALW6gAC1uwAAtbtAALW7gAC1vAAAtbxAALXFgAC1zoAAtdhAALXhQAC14gAAteKAALXjAAC144AAteQAALXkgAC15MAAteWAALXowAC17IAAte0AALXtgAC17gAAte6AALXvAAC174AAtfAAALXzwAC19IAAtfVAALX2AAC19sAAtfeAALX4QAC1+QAAtfmAALYJQAC2CcAAtgpAALYKwAC2C4AAtgvAALYMAAC2DEAAtgyAALYNAAC2DYAAtg3AALYOAAC2DoAAtg7AALYegAC2HwAAth+AALYgAAC2IMAAtiEAALYhQAC2IYAAtiHAALYiQAC2IsAAtiMAALYjQAC2I8AAtiQAALYzwAC2NEAAtjTAALY1QAC2NgAAtjZAALY2gAC2NsAAtjcAALY3gAC2OAAAtjhAALY4gAC2OQAAtjlAALZJAAC2SYAAtkoAALZKgAC2S0AAtkuAALZLwAC2TAAAtkxAALZMwAC2TUAAtk2AALZNwAC2TkAAtk6AALZeQAC2XsAAtl9AALZfwAC2YIAAtmDAALZhAAC2YUAAtmGAALZiAAC2YoAAtmLAALZjAAC2Y4AAtmPAALZzgAC2dAAAtnSAALZ1AAC2dcAAtnYAALZ2QAC2doAAtnbAALZ3QAC2d8AAtngAALZ4QAC2eMAAtnkAALaIwAC2iUAAtonAALaKQAC2iwAAtotAALaLgAC2i8AAtowAALaMgAC2jQAAto1AALaNgAC2jgAAto5AALahAAC2qcAAtrHAALa5wAC2ukAAtrrAALa7QAC2u8AAtryAALa8wAC2vQAAtr3AALa+AAC2vo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AAuYfAALmIQAC5iQAAuYlAALmJgAC5icAAuYoAALmKgAC5iwAAuYtAALmLgAC5jAAAuYxAALmcAAC5nIAAuZ0AALmdgAC5nkAAuZ6AALmewAC5nwAAuZ9AALmfwAC5oEAAuaCAALmgwAC5oUAAuaGAALmxQAC5scAAubJAALmywAC5s4AAubPAALm0AAC5tEAAubSAALm1AAC5tYAAubXAALm2AAC5toAAubbAALnAAAC5yQAAudLAALnbwAC53IAAud0AALndgAC53gAAud6AALnfAAC530AAueAAALnjQAC55wAAueeAALnoAAC56IAAuekAALnpgAC56gAAueqAALnuQAC57wAAue/AALnwgAC58UAAufIAALnywAC584AAufQAALoDwAC6BEAAugTAALoFQAC6BgAAugZAALoGgAC6BsAAugcAALoHgAC6CAAAughAALoIgAC6CQAAuglAALoZAAC6GYAAuhoAALoagAC6G0AAuhuAALobwAC6HAAAuhxAALocwAC6HUAAuh2AALodwAC6HkAAuh6AALouQAC6LsAAui9AALovwAC6MIAAujDAALoxAAC6MUAAujGAALoyAAC6MoAAujLAALozAAC6M4AAujPAALpDgAC6RAAAukSAALpFAAC6RcAAukYAALpGQAC6RoAAukbAALpHQAC6R8AAukgAALpIQAC6SMAAukkAALpYwAC6WUAAulnAALpaQAC6WwAAultAALpbgAC6W8AAulwAALpcgAC6XQAAul1AALpdgAC6XgAAul5AALpuAAC6boAAum8AALpvgAC6cEAAunCAALpwwAC6cQAAunFAALpxwAC6ckAAunKAALpywAC6c0AAunOAALqDQAC6g8AAuoRAALqEwAC6hYAAuoXAALqGAAC6hkAAuoaAALqHAAC6h4AAuofAALqIAAC6iIAAuojAALqbgAC6pEAAuqxAALq0QAC6tMAAurVAALq1wAC6tkAAurcAALq3QAC6t4AAurhAALq4gAC6uQAAurlAALq5wAC6uoAAurrAALq7AAC6u8AAurwAALq9QAC6wIAAusHAALrCQAC6wsAAusQAALrEwAC6xYAAusYAALrPQAC62EAAuuIAALrrAAC668AAuuxAALrswAC67UAAuu3AALruQAC67oAAuu9AALrygAC69sAAuvdAALr3wAC6+E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AAveOAAL3jwAC95EAAveTAAL3lAAC95UAAveXAAL3mAAC96UAAvemAAL3pwAC96kAAvfoAAL36gAC9+wAAvfuAAL38QAC9/IAAvfzAAL39AAC9/UAAvf3AAL3+QAC9/oAAvf7AAL3/QAC9/4AAvg9AAL4PwAC+EIAAvhEAAL4RwAC+EgAAvhJAAL4SgAC+EsAAvhNAAL4TwAC+FAAAvhRAAL4UwAC+FQAAvhjAAL4cAAC+HUAAvh4AAL4ewAC+IAAAviDAAL4hgAC+IgAAviNAAL42AAC+PsAAvkbAAL5OwAC+T0AAvk/AAL5QQAC+UMAAvlGAAL5RwAC+UgAAvlLAAL5TAAC+U4AAvlPAAL5UQAC+VQAAvlVAAL5VgAC+VkAAvlaAAL5XwAC+WwAAvlxAAL5cwAC+XUAAvl6AAL5fQAC+YAAAvmCAAL5pwAC+csAAvnyAAL6FgAC+hkAAvobAAL6HQAC+h8AAvohAAL6IwAC+iQAAvonAAL6NAAC+kUAAvpHAAL6SQAC+ksAAvpNAAL6TwAC+lEAAvpTAAL6VQAC+mYAAvppAAL6bAAC+m8AAvpyAAL6dQAC+ngAAvp7AAL6fgAC+oAAAvq/AAL6wQAC+sMAAvrFAAL6yAAC+skAAvrKAAL6ywAC+swAAvrOAAL60AAC+tEAAvrSAAL61AAC+tUAAvsUAAL7FgAC+xgAAvsaAAL7HQAC+x4AAvsfAAL7IAAC+yEAAvsjAAL7JQAC+yYAAvsnAAL7KQAC+yoAAvtpAAL7awAC+24AAvtwAAL7cwAC+3QAAvt1AAL7dgAC+3cAAvt5AAL7ewAC+3wAAvt9AAL7fwAC+4AAAvuNAAL7jgAC+48AAvuRAAL70AAC+9IAAvvUAAL71gAC+9kAAvvaAAL72wAC+9wAAvvdAAL73wAC++EAAvviAAL74wAC++UAAvvmAAL8JQAC/CcAAvwpAAL8KwAC/C4AAvwvAAL8MAAC/DEAAvwyAAL8NAAC/DYAAvw3AAL8OAAC/DoAAvw7AAL8egAC/HwAAvx+AAL8gAAC/IMAAvyEAAL8hQAC/IYAAvyHAAL8iQAC/IsAAvyMAAL8jQAC/I8AAvyQAAL8zwAC/NEAAvzTAAL81QAC/NgAAvzZAAL82gAC/NsAAvzcAAL83gAC/OAAAvzhAAL84gAC/OQAAvzlAAL9JAAC/SY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 + + + + + id + + + + index + + + + Chapter + Undefined + 2 + Chapter + 1 + + + + + + isFinished + + + + Book + Undefined + 6 + Book + 1 + + + + + + orderRank + + + + isFinished + + + + currentTime + + + + Theme + Undefined + 4 + Theme + 1 + + + + + + Bookmark + Undefined + 10 + Bookmark + 1 + + + + + + Account + Undefined + 5 + Account + 1 + + + + + + originalFileName + + + + providerName + + + + syncStatus + + + + type + + + + darkSystemFillHex + + + + darkTertiarySystemBackgroundHex + + + + id + + + + title + + + + lastSyncedAt + + + + 1 + lastPlayed + + + + speed + + + + type + + + + 1 + items + + + + 1 + library + + + + percentCompleted + + + + speed + + + + 1 + externalResources + + + + 1 + folder + + + + remoteURL + + + + 1 + bookmarks + + + + 1 + library + + + \ No newline at end of file diff --git a/Shared/Extensions/Notification+BookPlayerKit.swift b/Shared/Extensions/Notification+BookPlayerKit.swift index 6f86bedd6..4f79798fb 100644 --- a/Shared/Extensions/Notification+BookPlayerKit.swift +++ b/Shared/Extensions/Notification+BookPlayerKit.swift @@ -22,5 +22,14 @@ extension Notification.Name { public static let uploadProgressUpdated = Notification.Name("\(Bundle.main.configurationString(for: .bundleIdentifier)).upload.progress.update") public static let uploadCompleted = Notification.Name("\(Bundle.main.configurationString(for: .bundleIdentifier)).upload.completed") public static let listeningProgressChanged = Notification.Name("\(Bundle.main.configurationString(for: .bundleIdentifier)).listening.progress.changed") - + public static let newTaskInQueue = Notification.Name("\(Bundle.main.configurationString(for: .bundleIdentifier)).concurrent.task.queue") + /// A background URLSession delivered every event iOS held for it (after a relaunch or a + /// wake). `object` is the session's identifier. + public static let backgroundSessionFinishedEvents = Notification.Name("\(Bundle.main.configurationString(for: .bundleIdentifier)).background.session.finished.events") + /// Books were queued for upload (an import, or the first sync after signing in or + /// subscribing): the app may keep uploading in the background (a continued task) + public static let bookUploadsQueued = Notification.Name("\(Bundle.main.configurationString(for: .bundleIdentifier)).book.uploads.queued") + /// A queued task was parked on a coded server error and hasn't been reported yet. + /// `object` is the `QueuedSyncTask` (with its `pause`). + public static let syncTaskPaused = Notification.Name("\(Bundle.main.configurationString(for: .bundleIdentifier)).sync.task.paused") } diff --git a/Shared/Integrations/ExternalStreamResolver.swift b/Shared/Integrations/ExternalStreamResolver.swift new file mode 100644 index 000000000..6c1ea5c7a --- /dev/null +++ b/Shared/Integrations/ExternalStreamResolver.swift @@ -0,0 +1,110 @@ +// +// ExternalStreamResolver.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +/// Where a media-server item streams from, and what authorises the request. +public struct ExternalStreamSource: Equatable, Sendable { + public let url: URL + /// Custom headers first (reverse-proxy gates like Cloudflare Access), then the integration's + /// own Authorization, which always wins on conflict. + public let headers: [String: String] + + public init(url: URL, headers: [String: String]) { + self.url = url + self.headers = headers + } +} + +public protocol ExternalStreamResolving { + /// The stream source for a resource, or nil when no saved connection on THIS device matches + /// its host — which is what makes an item show as needing its server rather than silently + /// streaming from whichever server happens to be configured. + func streamSource(for resource: SimpleExternalResource) -> ExternalStreamSource? +} + +/// Resolves against the connections saved in the keychain. +/// +/// Injected rather than constructed inline so the resolution can be exercised in a test: this +/// logic used to sit in the middle of `PlaybackService.getPlayableChapters` with its own +/// `KeychainService()`, which meant nothing about streaming URLs, auth headers, or the +/// unresolved-host flag could be verified without a real keychain. +public struct ExternalStreamResolver: ExternalStreamResolving, BPLogger { + private let keychain: KeychainServiceProtocol + + public init(keychain: KeychainServiceProtocol = KeychainService()) { + self.keychain = keychain + } + + public func streamSource(for resource: SimpleExternalResource) -> ExternalStreamSource? { + switch resource.mediaServer { + case .jellyfin: + guard + let connection: JellyfinConnectionData = connection(for: resource, key: .jellyfinConnection) + else { return nil } + guard let url = URL(string: connection.buildDownloadUrl(providerId: resource.providerId)) else { + // A matched server whose URL won't build is a defect, not a missing connection — but + // the caller can only express "no source", so say so here. + Self.logger.error("Jellyfin connection resolved but no stream URL could be built for \(resource.providerId)") + return nil + } + + return ExternalStreamSource( + url: url, + headers: authorizing( + connection.customHeaders, + with: "MediaBrowser Token=\"\(connection.accessToken)\"" + ) + ) + + case .audiobookshelf: + guard + let connection: AudiobookShelfConnectionData = connection(for: resource, key: .audiobookshelfConnection) + else { return nil } + guard let url = URL(string: connection.buildAudiobookshelfDownloadUrl(providerId: resource.providerId)) else { + Self.logger.error("AudiobookShelf connection resolved but no stream URL could be built for \(resource.providerId)") + return nil + } + + return ExternalStreamSource( + url: url, + headers: authorizing(connection.customHeaders, with: "Bearer \(connection.apiToken)") + ) + + case nil: + // Hardcover, or a provider this build doesn't know: nothing to stream from. + return nil + } + } + + /// Resolved by stable host identity ONLY — no first-connection fallback. A resource whose + /// host matches no saved server must surface as missing (connect-your-server), not stream + /// from another instance where its provider id means nothing, or names a different book. + /// Shared contract with the Android app. + private func connection( + for resource: SimpleExternalResource, + key: KeychainKeys + ) -> T? { + let connections: [T] = (try? keychain.get(key)) ?? [] + + return IntegrationHostResolver.connection(for: resource.hostId, in: connections) + } + + /// Case-insensitive dedup: a user-configured lowercase "authorization" must not fight the + /// integration's own header (same rule as JellyfinHeaderInjector). + private func authorizing( + _ customHeaders: [String: String], + with authorization: String + ) -> [String: String] { + var headers = customHeaders.filter { + $0.key.caseInsensitiveCompare("Authorization") != .orderedSame + } + headers["Authorization"] = authorization + + return headers + } +} diff --git a/BookPlayer/MediaServerIntegration/Connection/IntegrationConnectionPayload.swift b/Shared/Integrations/IntegrationConnectionPayload.swift similarity index 98% rename from BookPlayer/MediaServerIntegration/Connection/IntegrationConnectionPayload.swift rename to Shared/Integrations/IntegrationConnectionPayload.swift index 79146fd4e..6d826007d 100644 --- a/BookPlayer/MediaServerIntegration/Connection/IntegrationConnectionPayload.swift +++ b/Shared/Integrations/IntegrationConnectionPayload.swift @@ -6,7 +6,6 @@ // Copyright © 2026 BookPlayer LLC. All rights reserved. // -import BookPlayerKit import Foundation /// A saved media-server connection, as persisted in the Keychain. diff --git a/BookPlayer/MediaServerIntegration/Connection/IntegrationConnectionStore.swift b/Shared/Integrations/IntegrationConnectionStore.swift similarity index 94% rename from BookPlayer/MediaServerIntegration/Connection/IntegrationConnectionStore.swift rename to Shared/Integrations/IntegrationConnectionStore.swift index fca463521..ce9bd5975 100644 --- a/BookPlayer/MediaServerIntegration/Connection/IntegrationConnectionStore.swift +++ b/Shared/Integrations/IntegrationConnectionStore.swift @@ -6,7 +6,6 @@ // Copyright © 2026 BookPlayer LLC. All rights reserved. // -import BookPlayerKit import Foundation /// Keychain-backed storage for a single integration's saved connections. @@ -104,7 +103,9 @@ final class IntegrationConnectionStore: B connections = [single] save() } else { - Self.logger.warning("failed to load connection data from keychain") + // Nil is also the completely normal never-added-a-server first launch — + // not warning-worthy on every boot + Self.logger.debug("no readable connection data in keychain (first launch, or decode failure)") return } @@ -210,6 +211,12 @@ final class IntegrationConnectionStore: B } private func save() { - try? keychain.set(connections, key: keychainKey) + do { + try keychain.set(connections, key: keychainKey) + } catch { + // In-memory state already moved on; without this a token that never + // persisted (and vanishes on next launch) would be undiagnosable + Self.logger.warning("failed to persist connection data to keychain: \(error)") + } } } diff --git a/Shared/Integrations/IntegrationDetailsViewModelProtocol.swift b/Shared/Integrations/IntegrationDetailsViewModelProtocol.swift new file mode 100644 index 000000000..f7767d1ae --- /dev/null +++ b/Shared/Integrations/IntegrationDetailsViewModelProtocol.swift @@ -0,0 +1,75 @@ +// +// IntegrationDetailsViewModelProtocol.swift +// BookPlayer +// +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +public protocol IntegrationDetailsDataProtocol: Hashable, Identifiable { + var artist: String? { get } + var filePath: String? { get } + var overview: String? { get } + var runtimeString: String { get } + var fileSizeString: String { get } + var genres: [String]? { get } + var tags: [String]? { get } + + // Optional — AudiobookShelf-specific, with defaults + var narrator: String? { get } + var seriesEntries: [IntegrationSeriesEntry] { get } +} + +extension IntegrationDetailsDataProtocol { + var narrator: String? { nil } + var seriesEntries: [IntegrationSeriesEntry] { [] } +} + +public struct IntegrationSeriesEntry: Identifiable, Hashable { + public let id: String + public let name: String + let sequence: String? + + public init(id: String, name: String, sequence: String?) { + self.id = id + self.name = name + self.sequence = sequence + } +} + +@MainActor +public protocol IntegrationDetailsViewModelProtocol: ObservableObject { + associatedtype Item: IntegrationLibraryItemProtocol + associatedtype Details: IntegrationDetailsDataProtocol + + var item: Item { get } + var details: Details? { get } + var error: Error? { get set } + + /// Entitlement-driven UI flags, computed LIVE from the account service (and + /// re-rendered via .accountUpdate): the Stream CTA pushes the subscribe flow, + /// so a purchase completed on the pushed screen must flip these on pop — + /// snapshot Bools captured at construction go stale exactly there. + var allowStream: Bool { get } + + /// Staged virtual-import selection awaiting user confirmation; drives the + /// confirmation sheet via `.sheet(item:)`. Set by the import pipeline, cleared + /// by the sheet's dismissal. + var pendingImportBatch: ExternalImportBatch? { get set } + + func fetchData() + func cancelFetchData() + func beginDownloadAudiobook(_ item: Item) throws + func virtualImportAudiobook(_ item: Item) async + func handleImportAudiobook(_ item: Item) async throws + + /// Hands the confirmed (possibly edited) selection to the import bus. + func confirmExternalImport(_ resources: [SimpleExternalResource]) + + /// Pushes the provider's subscribe screen onto the VM's OWN navigation stack. + /// In-stack navigation belongs to the VM (like the library VMs' goToSubscribe); + /// only container dismissal stays a presenter closure. + func goToSubscribe() +} diff --git a/BookPlayer/MediaServerIntegration/IntegrationError.swift b/Shared/Integrations/IntegrationError.swift similarity index 96% rename from BookPlayer/MediaServerIntegration/IntegrationError.swift rename to Shared/Integrations/IntegrationError.swift index 1d9ff8f13..cb7993c19 100644 --- a/BookPlayer/MediaServerIntegration/IntegrationError.swift +++ b/Shared/Integrations/IntegrationError.swift @@ -10,7 +10,7 @@ import Foundation /// `Equatable` is synthesized (every associated value already is) so tests can assert on a specific /// case instead of pattern-matching in every expectation. -enum IntegrationError: Error, LocalizedError, Equatable { +public enum IntegrationError: Error, LocalizedError, Equatable { case urlMalformed(_ url: URL?) case urlFromComponents(_ components: URLComponents) case noClient(_ integrationName: String) @@ -53,7 +53,7 @@ enum IntegrationError: Error, LocalizedError, Equatable { /// body is a short human-readable string, otherwise the bare status code. AudiobookShelf's auth /// endpoints answer in plain text, and those messages are the difference between "unexpected /// response 400" and "Invalid redirect_uri". - static func from(status: Int, body: Data) -> IntegrationError { + public static func from(status: Int, body: Data) -> IntegrationError { guard let text = String(data: body, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines), @@ -70,12 +70,12 @@ enum IntegrationError: Error, LocalizedError, Equatable { /// True when the error is a recoverable re-auth case — the UI should offer a Sign-In-only /// recovery path that preserves the existing connection instead of treating it as a generic /// load failure with the full Retry / Sign-In / Cancel set. - var isSessionExpired: Bool { + public var isSessionExpired: Bool { if case .sessionExpired = self { return true } return false } - var errorDescription: String? { + public var errorDescription: String? { switch self { case .urlMalformed(let url): String(format: "integration_internal_error_invalid_url".localized, String(reflecting: url)) diff --git a/BookPlayer/MediaServerIntegration/Auth/IntegrationHTTPClient.swift b/Shared/Integrations/IntegrationHTTPClient.swift similarity index 90% rename from BookPlayer/MediaServerIntegration/Auth/IntegrationHTTPClient.swift rename to Shared/Integrations/IntegrationHTTPClient.swift index 067d694b1..931c49ae0 100644 --- a/BookPlayer/MediaServerIntegration/Auth/IntegrationHTTPClient.swift +++ b/Shared/Integrations/IntegrationHTTPClient.swift @@ -10,7 +10,7 @@ import Foundation /// The HTTP surface the integration auth flows need, behind a protocol so those flows can be driven /// in tests without a network or a server. -protocol IntegrationHTTPClient: Sendable { +public protocol IntegrationHTTPClient: Sendable { func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) /// Issues `request` **without following redirects** and returns the URL it was redirected to. @@ -25,10 +25,10 @@ protocol IntegrationHTTPClient: Sendable { /// `URLSession`-backed client. Deliberately one long-lived session, so cookies set by one call are /// present on the next — the OIDC exchange depends on exactly that. -final class IntegrationURLSessionClient: IntegrationHTTPClient { +public final class IntegrationURLSessionClient: IntegrationHTTPClient { private let session: URLSession - init(timeout: TimeInterval = 15) { + public init(timeout: TimeInterval = 15) { let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = timeout // Spelled out rather than left to the defaults, because the OIDC flow's correctness rests on it. @@ -37,7 +37,7 @@ final class IntegrationURLSessionClient: IntegrationHTTPClient { self.session = URLSession(configuration: configuration) } - func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + public func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw IntegrationError.unexpectedResponse(code: nil) @@ -45,7 +45,7 @@ final class IntegrationURLSessionClient: IntegrationHTTPClient { return (data, http) } - func redirectLocation(for request: URLRequest) async throws -> URL { + public func redirectLocation(for request: URLRequest) async throws -> URL { let (data, response) = try await session.data(for: request, delegate: RedirectBlocker()) guard let http = response as? HTTPURLResponse else { throw IntegrationError.unexpectedResponse(code: nil) diff --git a/Shared/Integrations/IntegrationHostResolver.swift b/Shared/Integrations/IntegrationHostResolver.swift new file mode 100644 index 000000000..1918605a4 --- /dev/null +++ b/Shared/Integrations/IntegrationHostResolver.swift @@ -0,0 +1,86 @@ +// +// IntegrationHostResolver.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +/// A saved media-server connection that can be matched against a synced external resource's +/// `hostId`. Both `JellyfinConnectionData` and `AudiobookShelfConnectionData` conform. +public protocol IntegrationHostIdentifiable { + /// The server's own stable id (Jellyfin `System/Info.Id`, ABS login `server.id`), captured at + /// sign-in. Nil for connections saved before the server ever reported one. + var serverId: String? { get } + var url: URL { get } +} + +extension IntegrationHostIdentifiable { + /// The value written as `hostId` on external resources this connection imports — the + /// cross-device server identity. Contract shared with the Android app (its + /// `ExternalServiceUtils.stableHostId`): the server GUID when known, else the canonical + /// URL key (never the raw absolute string, so trailing-slash/port/case variants of one + /// logical server produce the same identity on every device). + public var stableHostId: String { + serverId ?? url.canonicalDedupKey + } +} + +/// Mirrors the Android app's `ExternalServiceUtils.serverForResource` resolution contract: +/// 1. stable server GUID, case-insensitive (Jellyfin reports lowercase hex, ABS uppercase UUIDs — +/// casing must never break the match); +/// 2. else canonical URL key (covers GUID-less servers and URL-fallback hostIds); +/// 3. else **nil** — deliberately NO first-connection fallback. Guessing a server streams the +/// wrong file or pushes progress to the wrong server when ids collide across instances; +/// an unresolvable host must surface as "connect your server", not silently misroute. +public enum IntegrationHostResolver { + public static func connection( + for hostId: String?, + in connections: [C] + ) -> C? { + guard let hostId, !hostId.isEmpty else { return nil } + if let byServerId = connections.first(where: { + $0.serverId?.caseInsensitiveCompare(hostId) == .orderedSame + }) { + return byServerId + } + return connections.first(where: { $0.url.canonicalDedupKey == hostId }) + } + + /// Display strings for each resource's host, keyed by providerId: the saved connection's + /// URL when the host resolves, else the raw `hostId` — which is all a device that never + /// added the server knows about it. + /// + /// Each provider decodes its own connection type from its own keychain key; the generic + /// `hostURL` is what lets one switch cover them all. + public static func hostDisplayStrings( + for resources: [SimpleExternalResource], + keychain: KeychainServiceProtocol + ) -> [String: String] { + resources.reduce(into: [:]) { hosts, resource in + let hostId = resource.hostId ?? "" + let url: URL? = + switch resource.mediaServer { + case .jellyfin: + hostURL(for: hostId, key: .jellyfinConnection, of: [JellyfinConnectionData].self, keychain: keychain) + case .audiobookshelf: + hostURL(for: hostId, key: .audiobookshelfConnection, of: [AudiobookShelfConnectionData].self, keychain: keychain) + case nil: + // Hardcover, or a provider this build doesn't know: no host to show but its own id. + nil + } + hosts[resource.providerId] = url?.absoluteString ?? hostId + } + } + + private static func hostURL( + for hostId: String, + key: KeychainKeys, + of type: [T].Type, + keychain: KeychainServiceProtocol + ) -> URL? { + let connections: [T] = (try? keychain.get(key)) ?? [] + return connection(for: hostId, in: connections)?.url + } +} diff --git a/Shared/Integrations/IntegrationLibraryItemProtocol.swift b/Shared/Integrations/IntegrationLibraryItemProtocol.swift new file mode 100644 index 000000000..43a9c7fbb --- /dev/null +++ b/Shared/Integrations/IntegrationLibraryItemProtocol.swift @@ -0,0 +1,21 @@ +// +// IntegrationLibraryItemProtocol.swift +// BookPlayer +// +// Created by Gianni Carlo on 4/5/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +public protocol IntegrationLibraryItemProtocol: Identifiable, Hashable { + var id: String { get } + var displayName: String { get } + /// A playable leaf item rather than a browsable container. The single predicate behind + /// row selection, select-all and the whole-level bulk actions, so a selection can never + /// contain something "everything here" would have skipped. Conformers also key artwork + /// and the disclosure chevron off it. + var isDownloadable: Bool { get } + var isNavigable: Bool { get } + var placeholderImageName: String { get } +} diff --git a/BookPlayer/MediaServerIntegration/Auth/PKCE.swift b/Shared/Integrations/PKCE.swift similarity index 99% rename from BookPlayer/MediaServerIntegration/Auth/PKCE.swift rename to Shared/Integrations/PKCE.swift index d5da475a0..df164f0b0 100644 --- a/BookPlayer/MediaServerIntegration/Auth/PKCE.swift +++ b/Shared/Integrations/PKCE.swift @@ -6,7 +6,6 @@ // Copyright © 2026 BookPlayer LLC. All rights reserved. // -import BookPlayerKit import CryptoKit import Foundation diff --git a/BookPlayer/MediaServerIntegration/Auth/WebAuthenticating.swift b/Shared/Integrations/WebAuthenticating.swift similarity index 92% rename from BookPlayer/MediaServerIntegration/Auth/WebAuthenticating.swift rename to Shared/Integrations/WebAuthenticating.swift index 0381e731b..44fadb960 100644 --- a/BookPlayer/MediaServerIntegration/Auth/WebAuthenticating.swift +++ b/Shared/Integrations/WebAuthenticating.swift @@ -6,8 +6,10 @@ // Copyright © 2026 BookPlayer LLC. All rights reserved. // +// The browser-based SSO handshake is iOS-only: ASWebAuthenticationSession presentation +// and UIKit anchors don't exist on watchOS, and the watch never signs in by itself. +#if !os(watchOS) import AuthenticationServices -import BookPlayerKit import Foundation import UIKit @@ -18,7 +20,7 @@ import UIKit /// escape hatch. Conformers are `@MainActor` classes, which are implicitly `Sendable`, so this costs /// them nothing — and it makes the isolation guarantee compiler-enforced rather than a comment. @MainActor -protocol WebAuthenticating: Sendable { +public protocol WebAuthenticating: Sendable { /// - Returns: the callback URL, e.g. `audiobookshelf://oauth?code=…&state=…`. /// - Throws: `CancellationError` when the user dismisses the sheet, so callers can stay silent on a /// deliberate cancel while still surfacing genuine failures. @@ -34,7 +36,7 @@ protocol WebAuthenticating: Sendable { /// Concentrating the session lifetime here is deliberate: getting it wrong produces a hard crash or /// a silent hang, and doing it once means every flow inherits the fix. @MainActor -final class WebAuthenticationSession: NSObject, WebAuthenticating, BPLogger { +public final class WebAuthenticationSession: NSObject, WebAuthenticating, BPLogger { /// Holds the in-flight handshake. Doubles as the strong reference the system session needs (it /// tears its sheet down if released while suspended) and as the single-flight guard. private var pending: WebAuthHandshake? @@ -47,11 +49,11 @@ final class WebAuthenticationSession: NSObject, WebAuthenticating, BPLogger { /// `nonisolated` so the owning services — which are built as `@Entry` environment placeholders /// outside any actor — can construct one as a default argument. Nothing isolated is touched here. - nonisolated override init() { + public nonisolated override init() { super.init() } - func authenticate( + public func authenticate( url: URL, callbackScheme: String, prefersEphemeralSession: Bool @@ -129,17 +131,18 @@ final class WebAuthenticationSession: NSObject, WebAuthenticating, BPLogger { /// The only window we'll present from: one belonging to a foreground-active scene, which is what /// `ASWebAuthenticationSession` documents as the requirement for a valid anchor. + /// Injected by the APP at startup: `UIApplication.shared` is statically unavailable in + /// this extension-safe framework, and reaching it via KVC string lookup risks App Store + /// static-analysis rejections. SSO only ever runs in the app process, which owns windows. + public static var foregroundWindowProvider: (@MainActor () -> UIWindow?)? + private static func foregroundWindow() -> UIWindow? { - let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } - guard let scene = scenes.first(where: { $0.activationState == .foregroundActive }) else { - return nil - } - return scene.keyWindow ?? scene.windows.first + foregroundWindowProvider?() } } extension WebAuthenticationSession: ASWebAuthenticationPresentationContextProviding { - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { // `anchor` is validated before the session starts, so the fallback is unreachable in practice — // it exists only because the protocol requires a non-optional return. anchor ?? ASPresentationAnchor() @@ -235,3 +238,5 @@ final class WebAuthHandshake: @unchecked Sendable { Task { @MainActor in session.cancel() } } } + +#endif diff --git a/Shared/Jellyfin/JellyfinAudiobookDetailsData.swift b/Shared/Jellyfin/JellyfinAudiobookDetailsData.swift new file mode 100644 index 000000000..3dbf855d8 --- /dev/null +++ b/Shared/Jellyfin/JellyfinAudiobookDetailsData.swift @@ -0,0 +1,73 @@ +// +// JellyfinDetailsData.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 23/3/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +public struct JellyfinAudiobookDetailsData: IntegrationDetailsDataProtocol { + public let id: String = UUID().uuidString + public let artist: String? + public let filePath: String? + public let fileSize: Int? + public let overview: String? + public let runtimeInSeconds: TimeInterval? + public let genres: [String]? + public let tags: [String]? + public let fileExtension: String? + + public let narrator: String? + public var seriesEntries: [IntegrationSeriesEntry] = [] + + public var fileSizeString: String { + if let fileSize { + ByteCountFormatter.string( + fromByteCount: Int64(fileSize), + countStyle: ByteCountFormatter.CountStyle.file + ) + } else { + "file_size_unknown".localized + } + } + + public var runtimeString: String { + if let runtimeInSeconds { + return TimeParser.formatTotalDuration(runtimeInSeconds) + } else { + return "runtime_unknown".localized + } + } + + public init( + artist: String? = nil, + filePath: String? = nil, + fileSize: Int? = nil, + fileExtension: String? = nil, + overview: String? = nil, + runtimeInSeconds: TimeInterval? = nil, + genres: [String]? = nil, + tags: [String]? = nil + ) { + self.artist = artist + self.filePath = filePath + self.fileSize = fileSize + self.overview = overview + self.runtimeInSeconds = runtimeInSeconds + self.genres = genres + self.tags = tags + self.narrator = nil + self.seriesEntries = [] + self.fileExtension = fileExtension + } +} + +public enum JellyfinLayout { + public enum Options: String { + case grid, list + } + + public enum SortBy: String { + case recent, name, smart + } +} diff --git a/Shared/Jellyfin/JellyfinLibraryItem.swift b/Shared/Jellyfin/JellyfinLibraryItem.swift new file mode 100644 index 000000000..30066cb75 --- /dev/null +++ b/Shared/Jellyfin/JellyfinLibraryItem.swift @@ -0,0 +1,260 @@ +// +// JellyfinLibraryItem.swift +// BookPlayer +// +// Created by Lysann Tranvouez on 2024-10-26. +// Copyright © 2024 BookPlayer LLC. All rights reserved. +// + +import Foundation +import JellyfinAPI + +public struct JellyfinLibraryItem: IntegrationLibraryItemProtocol { + public static func == (lhs: JellyfinLibraryItem, rhs: JellyfinLibraryItem) -> Bool { + return lhs.id == rhs.id + } + + public enum Kind { + case userView + case folder + case audiobook + case author + case narrator + } + + public let id: String + public let name: String + public let kind: Kind + + public let durationSeconds: Int64? + public let currentSeconds: Int64? + public let isFinished: Bool? + public let lastPlayedDate: Date? + public let blurHash: String? + public let imageAspectRatio: Double? + public let details: JellyfinAudiobookDetailsData? + /// The server's chapter list, empty unless this item came from a payload that requested + /// `ItemFields.chapters`. A streamed item has no other source — nothing opens the file. + public let chapters: [ChapterMetadata] + + public var isDownloadable: Bool { + kind == .audiobook + } + + public var isNavigable: Bool { + !isDownloadable + } + + public var displayName: String { name } + + public var placeholderImageName: String { + switch kind { + case .audiobook: "waveform" + case .userView, .folder: "folder" + case .author: "person" + case .narrator: "mic" + } + } +} + +extension JellyfinLibraryItem { + public init(id: String, name: String, kind: Kind) { + self.init( + id: id, + name: name, + kind: kind, + durationSeconds: nil, + currentSeconds: nil, + isFinished: false, + lastPlayedDate: nil, + blurHash: nil, + imageAspectRatio: nil, + details: nil, + chapters: [] + ) + } +} + +extension JellyfinLibraryItem { + /// Jellyfin only reports a runtime at the item level once it has probed the file, but the + /// media source can carry one on its own — and a virtual import is REFUSED without a + /// runtime, so take it wherever it exists. + /// + /// Static and tick-typed rather than inline in the mapper so the resolution can be tested + /// without a `BaseItemDto`: `BookPlayerTests` declares no JellyfinAPI dependency, so a test + /// that names one of its types compiles but fails to LINK. + static func resolveRuntimeSeconds(itemTicks: Int?, mediaSourceTicks: Int?) -> TimeInterval? { + (itemTicks ?? mediaSourceTicks).map { TimeInterval($0) / 10000000.0 } + } + + /// Jellyfin reports only a START per chapter, so each duration is the gap to the next one + /// and the last runs to the item's runtime. Without a runtime the final chapter has no + /// end, so the whole list is dropped rather than storing one with a bogus length. + /// + /// Tick-typed pairs rather than `[ChapterInfo]` so this is testable without JellyfinAPI, + /// which `BookPlayerTests` does not link. + static func chapterMetadata( + from chapters: [(name: String?, startTicks: Int?)], + runtimeSeconds: TimeInterval? + ) -> [ChapterMetadata] { + guard let runtimeSeconds, runtimeSeconds > 0 else { return [] } + + let starts = chapters + .compactMap { chapter -> (String, TimeInterval)? in + guard let startTicks = chapter.startTicks else { return nil } + return (chapter.name ?? "", TimeInterval(startTicks) / 10000000.0) + } + .filter { $0.1 < runtimeSeconds } + .sorted { $0.1 < $1.1 } + + return starts.enumerated().compactMap { index, entry in + let end = index + 1 < starts.count ? starts[index + 1].1 : runtimeSeconds + let duration = end - entry.1 + guard duration > 0 else { return nil } + + return ChapterMetadata(title: entry.0, start: entry.1, duration: duration, index: index + 1) + } + } + + /// The playable file's extension: Jellyfin reports a media source's container, which can be + /// a comma-separated list of candidates, and falls back to the path's own extension. + /// + /// Shared with `JellyfinConnectionService.fetchItemDetails` for the same reason as the + /// runtime — the two producers of `JellyfinAudiobookDetailsData` must not disagree about + /// whether an item is importable. + static func resolveFileExtension(container: String?, filePath: String?) -> String? { + container?.components(separatedBy: ",").first + ?? container + ?? (filePath as NSString?)?.pathExtension + } + + public init?(apiItem: BaseItemDto) { + let kind: JellyfinLibraryItem.Kind? = switch apiItem.type { + case .userView, .collectionFolder: .userView + case .folder: .folder + case .audioBook: .audiobook + default: nil + } + + guard let id = apiItem.id, let kind else { + return nil + } + let name = apiItem.name ?? id + let blurHash = apiItem.imageBlurHashes?.primary?.first?.value + + let artist = apiItem.albumArtist ?? apiItem.artists?.first + let filePath = apiItem.mediaSources?.first?.path ?? apiItem.path + let runtimeInSeconds = Self.resolveRuntimeSeconds( + itemTicks: apiItem.runTimeTicks, + mediaSourceTicks: apiItem.mediaSources?.first?.runTimeTicks + ) + let fileExtension = Self.resolveFileExtension( + container: apiItem.mediaSources?.first?.container, + filePath: filePath + ) + + var myDetails: JellyfinAudiobookDetailsData? = nil + if artist != nil || filePath != nil || runtimeInSeconds != nil || fileExtension != nil { + myDetails = JellyfinAudiobookDetailsData( + artist: artist, + filePath: filePath, + fileSize: apiItem.mediaSources?.first?.size, + fileExtension: fileExtension, + overview: apiItem.overview, + runtimeInSeconds: runtimeInSeconds, + genres: apiItem.genres, + tags: apiItem.tags + ) + } + + self.init( + id: id, + name: name, + kind: kind, + durationSeconds: Int64(runtimeInSeconds ?? 0), + currentSeconds: Int64((apiItem.userData?.playbackPositionTicks ?? 0) / 10000000), + isFinished: apiItem.userData?.isPlayed, + lastPlayedDate: apiItem.userData?.lastPlayedDate, + blurHash: blurHash, + imageAspectRatio: apiItem.primaryImageAspectRatio, + details: myDetails, + chapters: Self.chapterMetadata( + from: (apiItem.chapters ?? []).map { (name: $0.name, startTicks: $0.startPositionTicks) }, + runtimeSeconds: runtimeInSeconds + ) + ) + } + + /// Create an author item from an AlbumArtists API response + init?(authorApiItem: BaseItemDto) { + guard let id = authorApiItem.id else { return nil } + let name = authorApiItem.name ?? id + let blurHash = authorApiItem.imageBlurHashes?.primary?.first?.value + self.init(id: id, name: name, kind: .author, durationSeconds: Int64((authorApiItem.runTimeTicks ?? 0) / 10000000), currentSeconds: Int64((authorApiItem.userData?.playbackPositionTicks ?? 0) / 10000000), isFinished: authorApiItem.userData?.isPlayed, + lastPlayedDate: authorApiItem.userData?.lastPlayedDate, blurHash: blurHash, imageAspectRatio: authorApiItem.primaryImageAspectRatio, details: nil, chapters: []) + } + + /// Create a narrator item from a Persons API response + init?(narratorApiItem: BaseItemDto) { + guard let id = narratorApiItem.id else { return nil } + let name = narratorApiItem.name ?? id + let blurHash = narratorApiItem.imageBlurHashes?.primary?.first?.value + self.init(id: id, name: name, kind: .narrator, durationSeconds: Int64((narratorApiItem.runTimeTicks ?? 0) / 10000000), currentSeconds: Int64((narratorApiItem.userData?.playbackPositionTicks ?? 0) / 10000000), isFinished: narratorApiItem.userData?.isPlayed, + lastPlayedDate: narratorApiItem.userData?.lastPlayedDate, blurHash: blurHash, imageAspectRatio: narratorApiItem.primaryImageAspectRatio, details: nil, chapters: []) + } +} + +// MARK: - Virtual import + +extension JellyfinLibraryItem { + /// Builds the virtual-import payload for this item. The file extension and the duration + /// are REQUIRED and both come from hydration (`VirtualImportPipeline`): callers hydrate + /// from the server (`fetchItems(ids:)` requests media sources) and SKIP items that have + /// neither. An item without audio-file metadata has nothing to stream, so the extension + /// is never guessed; one Jellyfin has not probed has no runtime, and importing it with a + /// 0 duration yields a row that can never play. + @MainActor + public func asVirtualImportResource( + fileExtension: String, + duration: TimeInterval, + chapters: [ChapterMetadata] = [], + detailsOverride: JellyfinAudiobookDetailsData?, + connectionService: JellyfinConnectionService, + artworkSize: CGSize + ) -> SimpleExternalResource { + let resolvedDetails = detailsOverride ?? details + let libraryItem = SimpleLibraryItem( + title: name, + details: resolvedDetails?.artist ?? "voiceover_unknown_author".localized, + speed: 1, + currentTime: Double(currentSeconds ?? 0), + duration: duration, + /// The duration half of the old guard is now the pipeline's precondition, so the + /// division is safe on any item that reaches here. + percentCompleted: (currentSeconds ?? 0) > 0 + ? Double(currentSeconds!) / duration * 100 + : 0, + isFinished: isFinished ?? false, + relativePath: "", + remoteURL: nil, + artworkURL: try? connectionService.createItemImageURL(self, size: artworkSize), + orderRank: 0, + parentFolder: nil, + originalFileName: "\(name).\(fileExtension)", + lastPlayDate: lastPlayedDate, + type: .book, + uuid: UUID().uuidString + ) + + return SimpleExternalResource( + id: abs(UUID().hashValue), // unique per element — a shared timestamp collides Identifiable ids within a batch + providerName: ExternalResource.ProviderName.jellyfin.rawValue, + providerId: id, + syncStatus: ExternalResource.SyncStatus.stream.rawValue, + lastSyncedAt: nil, + hostId: connectionService.connection?.stableHostId, + libraryItem: libraryItem, + chapters: chapters + ) + } +} diff --git a/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift b/Shared/Jellyfin/Network/JellyfinConnectionData.swift similarity index 59% rename from BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift rename to Shared/Jellyfin/Network/JellyfinConnectionData.swift index 64611c48a..f1ffba597 100644 --- a/BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift +++ b/Shared/Jellyfin/Network/JellyfinConnectionData.swift @@ -8,22 +8,24 @@ import Foundation -struct JellyfinConnectionData: Codable, Identifiable { - let id: String - let url: URL - let serverName: String - let userID: String - let userName: String - let accessToken: String - var selectedLibraryId: String? - var customHeaders: [String: String] = [:] +public struct JellyfinConnectionData: Codable, Identifiable, IntegrationHostIdentifiable { + public let id: String + public let serverId: String? + public let url: URL + public let serverName: String + public let userID: String + public let userName: String + public let accessToken: String + public var selectedLibraryId: String? + public var customHeaders: [String: String] = [:] enum CodingKeys: String, CodingKey { - case id, url, serverName, userID, userName, accessToken, selectedLibraryId, customHeaders + case id, serverId, url, serverName, userID, userName, accessToken, selectedLibraryId, customHeaders } init( id: String = UUID().uuidString, + serverId: String? = nil, url: URL, serverName: String, userID: String, @@ -33,6 +35,7 @@ struct JellyfinConnectionData: Codable, Identifiable { customHeaders: [String: String] = [:] ) { self.id = id + self.serverId = serverId self.url = url self.serverName = serverName self.userID = userID @@ -42,9 +45,10 @@ struct JellyfinConnectionData: Codable, Identifiable { self.customHeaders = customHeaders } - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString + self.serverId = try? container.decodeIfPresent(String.self, forKey: .serverId) self.url = try container.decode(URL.self, forKey: .url) self.serverName = try container.decode(String.self, forKey: .serverName) self.userID = try container.decode(String.self, forKey: .userID) @@ -62,8 +66,16 @@ extension JellyfinConnectionData: IntegrationConnectionPayload { } extension JellyfinConnectionData: CustomDebugStringConvertible { - var debugDescription: String { + public var debugDescription: String { let accessTokenDebugDesc = accessToken.isEmpty ? "" : "" - return "JellyfinConnectionData(\(url), \(serverName), \(userID), \(userName), \(accessTokenDebugDesc))" + let serverIdDesc = serverId ?? "" + return "JellyfinConnectionData(\(url), serverId: \(serverIdDesc), \(serverName), \(userID), \(userName), \(accessTokenDebugDesc))" + } + + public func buildDownloadUrl(providerId: String) -> String { + // appendingPathComponent normalizes the trailing slash a user may have typed in + // the server URL — string concatenation yields "host//items/…", which some reverse + // proxies (nginx/Cloudflare) 404 + return self.url.appendingPathComponent("items/\(providerId)/Download").absoluteString } } diff --git a/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift b/Shared/Jellyfin/Network/JellyfinConnectionService.swift similarity index 76% rename from BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift rename to Shared/Jellyfin/Network/JellyfinConnectionService.swift index f99a54aaf..fabc66390 100644 --- a/BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift +++ b/Shared/Jellyfin/Network/JellyfinConnectionService.swift @@ -6,11 +6,16 @@ // Copyright © 2024 BookPlayer LLC. All rights reserved. // -import BookPlayerKit import Get import JellyfinAPI import os +#if os(watchOS) +import WatchKit +#else +import UIKit +#endif + /// Applies user-defined custom HTTP headers (e.g. Cloudflare Access Service Tokens) /// to every outgoing `JellyfinClient` request. Skips `Authorization` so the Jellyfin /// client's own MediaBrowser token is never overwritten. @@ -18,18 +23,18 @@ import os /// `willSendRequest` is invoked on `Get.APIClient`'s actor executor, while /// `setCustomHeaders(_:)` is typically called from `@MainActor`. The dictionary /// is therefore guarded by `OSAllocatedUnfairLock`. -final class JellyfinHeaderInjector: APIClientDelegate, @unchecked Sendable { +public final class JellyfinHeaderInjector: APIClientDelegate, @unchecked Sendable { private let lockedHeaders: OSAllocatedUnfairLock<[String: String]> - init(customHeaders: [String: String] = [:]) { + public init(customHeaders: [String: String] = [:]) { self.lockedHeaders = OSAllocatedUnfairLock(initialState: customHeaders) } - func setCustomHeaders(_ headers: [String: String]) { + public func setCustomHeaders(_ headers: [String: String]) { lockedHeaders.withLock { $0 = headers } } - func client(_ client: APIClient, willSendRequest request: inout URLRequest) async throws { + public func client(_ client: APIClient, willSendRequest request: inout URLRequest) async throws { let headers = lockedHeaders.withLock { $0 } for (key, value) in headers where key.caseInsensitiveCompare("Authorization") != .orderedSame { request.setValue(value, forHTTPHeaderField: key) @@ -39,7 +44,7 @@ final class JellyfinHeaderInjector: APIClientDelegate, @unchecked Sendable { @MainActor @Observable -class JellyfinConnectionService: BPLogger { +public class JellyfinConnectionService: BPLogger { /// `nonisolated` so the `nonisolated init` below can read it without crossing isolation. private nonisolated static let activeConnectionIDKey = "jellyfin_active_connection_id" @@ -57,14 +62,36 @@ class JellyfinConnectionService: BPLogger { /// on another device before this one gives up. private static let quickConnectMaxPolls = 200 - var connections: [JellyfinConnectionData] { store.connections } - var connection: JellyfinConnectionData? { store.active } - var client: JellyfinClient? - private var headerInjector: JellyfinHeaderInjector? + public var connections: [JellyfinConnectionData] { store.connections } + /// A connection pinned by the sync/stream engine for a specific external resource — + /// overrides the UI's active selection until cleared. Set via ``useConnection(_:)``. + private var manualConnection: JellyfinConnectionData? + public var connection: JellyfinConnectionData? { + if let manualConnection { return manualConnection } + return store.active + } + + /// Pins `connection` and rebuilds the client against it (used when consuming a stream + /// for a resource whose host resolved to a non-active saved server). + public func useConnection(_ connection: JellyfinConnectionData) { + manualConnection = connection + rebuildClient(for: connection) + } + + /// Vendor device identifier forwarded to Jellyfin as the client device id. + public var deviceIdentifier: String? { +#if os(watchOS) + return WKInterfaceDevice.current().identifierForVendor?.uuidString +#else + return UIDevice.current.identifierForVendor?.uuidString +#endif + } + public var client: JellyfinClient? + public var headerInjector: JellyfinHeaderInjector? - var activeConnectionID: String? { store.activeConnectionID } + public var activeConnectionID: String? { store.activeConnectionID } - nonisolated init(keychainService: KeychainServiceProtocol = KeychainService()) { + public nonisolated init(keychainService: KeychainServiceProtocol = KeychainService()) { self.store = IntegrationConnectionStore( keychainKey: .jellyfinConnection, activeIDDefaultsKey: Self.activeConnectionIDKey, @@ -72,7 +99,7 @@ class JellyfinConnectionService: BPLogger { ) } - func setup() { + public func setup() { store.reload() // Bring the live client up for whichever connection ended up active. rebuildClient(for: connection) @@ -83,14 +110,14 @@ class JellyfinConnectionService: BPLogger { /// the connection. Until that commit, neither `self.client` nor `self.headerInjector` /// are mutated, so an in-flight Add Server flow can't corrupt the active library /// session. - struct PendingServer { - let serverName: String - let client: JellyfinClient - let injector: JellyfinHeaderInjector + public struct PendingServer { + public let serverName: String + public let client: JellyfinClient + public let injector: JellyfinHeaderInjector /// Whether this server reports Quick Connect as enabled. Probed during `findServer` so the UI can /// offer the affordance only when it can actually work — admins can turn Quick Connect off, and a /// button that always appears sends those users into a flow that fails with a server error. - let quickConnectEnabled: Bool + public let quickConnectEnabled: Bool } /// Validates server reachability without mutating service state. Returns a @@ -170,9 +197,15 @@ class JellyfinConnectionService: BPLogger { // off (same library context, same outbound references) rather than on a fresh record they have // to re-configure. let serverURL = pending.client.configuration.url + // Capture the server's stable GUID (System/Info id) — the cross-device host identity for + // synced external resources. Best-effort: a failed info call must never fail the sign-in, + // and must not wipe a previously captured id. + let publicSystemInfo = try? await pending.client.send(Paths.getPublicSystemInfo) + let serverId = publicSystemInfo?.value.id store.upsert(url: serverURL, userID: userID, replacingID: replacingID) { existing in JellyfinConnectionData( id: existing?.id ?? UUID().uuidString, + serverId: serverId ?? existing?.serverId, url: serverURL, serverName: serverName, userID: userID, @@ -194,7 +227,7 @@ class JellyfinConnectionService: BPLogger { /// The reworked add-server flow keeps the `JellyfinClient` transient inside ``PendingServer`` /// and only commits it on a successful sign-in. Quick Connect needs a working client *before* /// any credentials exist, so it runs against the pending client rather than `self.client`. - func makeQuickConnectController( + public func makeQuickConnectController( using client: JellyfinClient ) -> JellyfinAPI.QuickConnect { JellyfinAPI.QuickConnect( @@ -234,9 +267,13 @@ class JellyfinConnectionService: BPLogger { // and carries the existing connection's id and selectedLibraryId forward on a re-auth, so the // user lands back in the same library context with the same outbound references. let serverURL = pending.client.configuration.url + // Same stable-GUID capture as the password path (see signIn). + let publicSystemInfo = try? await pending.client.send(Paths.getPublicSystemInfo) + let serverId = publicSystemInfo?.value.id store.upsert(url: serverURL, userID: userID, replacingID: replacingID) { existing in JellyfinConnectionData( id: existing?.id ?? UUID().uuidString, + serverId: serverId ?? existing?.serverId, url: serverURL, serverName: serverName, userID: userID, @@ -255,31 +292,31 @@ class JellyfinConnectionService: BPLogger { return userName } - func updateCustomHeaders(_ headers: [String: String]) { + public func updateCustomHeaders(_ headers: [String: String]) { guard let activeID = connection?.id else { return } updateCustomHeaders(id: activeID, headers) } /// Persist `headers` to the connection with the given id, regardless of which is active. /// If `id` happens to be the active connection, the live header injector is also updated. - func updateCustomHeaders(id: String, _ headers: [String: String]) { + public func updateCustomHeaders(id: String, _ headers: [String: String]) { guard store.updateCustomHeaders(id: id, headers) else { return } if id == connection?.id { headerInjector?.setCustomHeaders(headers) } } - func saveSelectedLibrary(id: String?) { + public func saveSelectedLibrary(id: String?) { guard let activeID = connection?.id else { return } store.setSelectedLibrary(id: activeID, libraryId: id) } - func activateConnection(id: String) { + public func activateConnection(id: String) { guard store.setActive(id: id) else { return } rebuildClient(for: connection) } - func deleteConnection(id: String) { + public func deleteConnection(id: String) { // Capture the old client BEFORE we mutate state, so the fire-and-forget // signOut doesn't race with the rebuildClient call below (and so signOut // is invoked on the connection the user actually asked us to remove). @@ -323,7 +360,7 @@ class JellyfinConnectionService: BPLogger { ) } - func deleteConnection() { + public func deleteConnection() { if let id = connection?.id { deleteConnection(id: id) } @@ -376,14 +413,17 @@ class JellyfinConnectionService: BPLogger { sortOrder = [.ascending] } - let isRecursive = recursive || searchTerm != nil || folderID == nil - let itemTypes: [JellyfinAPI.BaseItemKind] = (recursive || searchTerm != nil) ? [.audioBook] : [.audioBook, .folder] + // Use the normalized term everywhere below: a raw empty string ("") would otherwise + // count as "searching" — forcing a recursive audiobook-only fetch that drops the + // folder's subfolders and sends searchTerm="" to the server + let isRecursive = recursive || effectiveSearchTerm != nil || folderID == nil + let itemTypes: [JellyfinAPI.BaseItemKind] = (recursive || effectiveSearchTerm != nil) ? [.audioBook] : [.audioBook, .folder] let parameters = Paths.GetItemsParameters( startIndex: startIndex, limit: limit, isRecursive: isRecursive, - searchTerm: searchTerm, + searchTerm: effectiveSearchTerm, sortOrder: sortOrder, parentID: folderID, fields: [.sortName], @@ -412,6 +452,31 @@ class JellyfinConnectionService: BPLogger { return (items, nextStartItemIndex, maxNumItems) } + /// Hydrates the given item ids with their media sources (container/path), which list + /// responses don't carry. Virtual import requires the REAL file extension — items the + /// server reports without one are skipped by the importer, never guessed. + public func fetchItems(ids: [String]) async throws -> [JellyfinLibraryItem] { + guard !ids.isEmpty else { return [] } + + var hydrated: [JellyfinLibraryItem] = [] + // The ids travel in the query string — chunk so a whole-folder import can't + // overflow the URL length limit. + let chunkSize = 100 + for start in stride(from: 0, to: ids.count, by: chunkSize) { + let chunk = Array(ids[start.. JellyfinLibraryItem? { + let response = try await send(Paths.getItem(itemID: id)) + try Task.checkCancellation() + return JellyfinLibraryItem(apiItem: response.value) + } + + /// Pushes playback progress for one item (`updateItemUserData`), mirroring what the Android + /// app's external-update task sends. Routed through ``send(_:)`` so a mid-session 401/403 + /// surfaces as the typed re-auth signal instead of a generic error. + public func updateItemProgress(_ itemId: String, positionTicks: Int, percentCompleted: Double) async throws { + let userDataDto = UpdateUserItemDataDto( + playbackPositionTicks: positionTicks, + playedPercentage: percentCompleted + ) + _ = try await send(Paths.updateItemUserData(itemID: itemId, userDataDto)) + } + + /// Batch-fetches the Jellyfin items backing the given synced external resources, keyed by + /// provider item id — one round-trip instead of N when refreshing a list. + public func updateItemsFromJellyfin( + _ externalResources: [SimpleExternalResource], + includingChapters: Bool + ) async throws -> [String: JellyfinLibraryItem] { + guard !externalResources.isEmpty, let myId = connection?.userID else { return [:] } + + var parameters = Paths.GetItemsParameters(userID: myId, ids: externalResources.map(\.providerId)) + /// Both fields are opt-in and both are needed together, but ONLY for the items that still + /// have no chapters: `Chapters` is the list itself, and `MediaSources` carries the runtime + /// that bounds the last chapter for an item Jellyfin hasn't probed at the item level — + /// without a runtime the whole list is dropped. A progress-only refresh asks for neither, + /// which is the payload this call carried before chapters existed. + if includingChapters { + parameters.fields = [.chapters, .mediaSources] + } + + let response = try await send(Paths.getItems(parameters: parameters)) + + var itemsDictionary: [String: JellyfinLibraryItem] = [:] + for item in response.value.items ?? [] { + guard let jellyfinItem = JellyfinLibraryItem(apiItem: item) else { continue } + itemsDictionary[jellyfinItem.id] = jellyfinItem + } + return itemsDictionary + } + public func fetchAudiobookDownloadRequests(for folderID: String) async throws -> [URLRequest] { let parameters = Paths.GetItemsParameters( isRecursive: false, @@ -682,7 +803,13 @@ class JellyfinConnectionService: BPLogger { let mainBundleInfo = Bundle.main.infoDictionary let clientName = mainBundleInfo?[kCFBundleNameKey as String] as? String let clientVersion = mainBundleInfo?[kCFBundleVersionKey as String] as? String +#if os(watchOS) + let deviceID = WKInterfaceDevice.current().identifierForVendor + let deviceName = WKInterfaceDevice.current().name +#else let deviceID = UIDevice.current.identifierForVendor + let deviceName = UIDevice.current.name +#endif guard let url = URL(string: serverUrlString), let clientName, let clientVersion, let deviceID else { Self.logger.error( "cannot build Jellyfin API client. \(serverUrlString), \(clientName), \(clientVersion), \(String(reflecting: deviceID))" @@ -692,7 +819,7 @@ class JellyfinConnectionService: BPLogger { let configuration = JellyfinClient.Configuration( url: url, client: clientName, - deviceName: UIDevice.current.name, + deviceName: deviceName, deviceID: "\(deviceID.uuidString)-\(clientName)", version: clientVersion ) @@ -723,17 +850,13 @@ class JellyfinConnectionService: BPLogger { return client } - func createItemDownloadUrl(_ item: JellyfinLibraryItem) throws -> URL { + public func createItemDownloadUrl(_ item: JellyfinLibraryItem) throws -> URL { guard let client else { throw IntegrationError.noClient("Jellyfin") } let request = Paths.getDownload(itemID: item.id) - var components = try createUrlComponentsForApiRequest(request) - - var queryItems = components.queryItems ?? [] - queryItems.append(URLQueryItem(name: "api_key", value: client.accessToken)) - components.queryItems = queryItems + let components = try createUrlComponentsForApiRequest(request) guard let url = components.url else { throw IntegrationError.urlFromComponents(components) @@ -744,14 +867,21 @@ class JellyfinConnectionService: BPLogger { /// Returns a URLRequest for downloading a library item, carrying the user-defined /// custom HTTP headers (needed for servers behind Cloudflare Access etc.). - func createItemDownloadRequest(_ item: JellyfinLibraryItem) throws -> URLRequest { + public func createItemDownloadRequest(_ item: JellyfinLibraryItem) throws -> URLRequest { let url = try createItemDownloadUrl(item) - return wrapWithCustomHeaders(url) + var request = wrapWithCustomHeaders(url) + // Token rides Jellyfin's native header, NOT the query string: URLs land in server + // logs, proxy logs, and download-manager UI, and the caller builds a URLRequest + // anyway. Background URLSession preserves request headers across app relaunches. + if let token = client?.accessToken { + request.setValue(token, forHTTPHeaderField: "X-Emby-Token") + } + return request } /// Wraps an arbitrary URL (e.g. a cover image) in a URLRequest carrying the current /// connection's custom HTTP headers. Skips `Authorization` so the Jellyfin token is preserved. - func wrapWithCustomHeaders(_ url: URL) -> URLRequest { + public func wrapWithCustomHeaders(_ url: URL) -> URLRequest { var request = URLRequest(url: url) for (key, value) in connection?.customHeaders ?? [:] where key.caseInsensitiveCompare("Authorization") != .orderedSame { @@ -760,7 +890,7 @@ class JellyfinConnectionService: BPLogger { return request } - func createItemImageURL(_ item: JellyfinLibraryItem, size: CGSize?, quality: Int? = nil) throws -> URL { + public func createItemImageURL(_ item: JellyfinLibraryItem, size: CGSize?, quality: Int? = nil) throws -> URL { var parameters = Paths.GetItemImageParameters() if let size { diff --git a/Shared/Network/BPDownloadURLSession.swift b/Shared/Network/BPDownloadURLSession.swift index bd3d5bf44..7e2ec16e6 100644 --- a/Shared/Network/BPDownloadURLSession.swift +++ b/Shared/Network/BPDownloadURLSession.swift @@ -21,8 +21,6 @@ public class BPDownloadURLSession { downloadProgressUpdated: @escaping ((URLSessionDownloadTask, Double) -> Void), didFinishDownloadingTask: @escaping ((URLSessionTask, URL?, Error?) -> Void) ) { - let bundleIdentifier: String = Bundle.main.configurationValue(for: .bundleIdentifier) - let delegate = BPTaskDownloadDelegate() delegate.downloadProgressUpdated = downloadProgressUpdated @@ -30,10 +28,11 @@ public class BPDownloadURLSession { self.backgroundSession = URLSession( configuration: URLSessionConfiguration.background( - withIdentifier: "\(bundleIdentifier).background.download" + withIdentifier: BackgroundTransferSessions.downloadIdentifier ), delegate: delegate, - delegateQueue: OperationQueue() + // Serial, so "finished events" follows the last download's callbacks + delegateQueue: BPURLSession.serialDelegateQueue() ) } } diff --git a/Shared/Network/BPTaskDownloadDelegate.swift b/Shared/Network/BPTaskDownloadDelegate.swift index 5e5978af9..af084d820 100644 --- a/Shared/Network/BPTaskDownloadDelegate.swift +++ b/Shared/Network/BPTaskDownloadDelegate.swift @@ -48,6 +48,13 @@ public class BPTaskDownloadDelegate: NSObject, URLSessionDownloadDelegate { ) } + public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + NotificationCenter.default.post( + name: .backgroundSessionFinishedEvents, + object: session.configuration.identifier + ) + } + /// Note: this gets called even if there's no error public func urlSession( _ session: URLSession, diff --git a/Shared/Network/BPTaskUploadDelegate.swift b/Shared/Network/BPTaskUploadDelegate.swift index 728d97a7e..f50041b59 100644 --- a/Shared/Network/BPTaskUploadDelegate.swift +++ b/Shared/Network/BPTaskUploadDelegate.swift @@ -9,8 +9,9 @@ import Foundation class BPTaskUploadDelegate: NSObject, URLSessionTaskDelegate { - /// Callback triggered when there's an update on the upload progress - var uploadProgressUpdated: ((URLSessionTask, Double) -> Void)? + /// Callback triggered when there's an update on the upload progress, with the task's + /// bytes sent so far (a multipart upload sums them across its parts) + var uploadProgressUpdated: ((URLSessionTask, Int64) -> Void)? /// Callback triggered when the download task is finished var didFinishTask: ((URLSessionTask, Error?) -> Void)? @@ -21,11 +22,17 @@ class BPTaskUploadDelegate: NSObject, URLSessionTaskDelegate { totalBytesSent: Int64, totalBytesExpectedToSend: Int64 ) { - let uploadProgress = Float(totalBytesSent) / Float(totalBytesExpectedToSend) - uploadProgressUpdated?(task, Double(uploadProgress)) + uploadProgressUpdated?(task, totalBytesSent) } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { didFinishTask?(task, error) } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + NotificationCenter.default.post( + name: .backgroundSessionFinishedEvents, + object: session.configuration.identifier + ) + } } diff --git a/Shared/Network/BPURLSession.swift b/Shared/Network/BPURLSession.swift index eef43a95f..29ab422b9 100644 --- a/Shared/Network/BPURLSession.swift +++ b/Shared/Network/BPURLSession.swift @@ -9,26 +9,42 @@ import Combine import Foundation +/// The app's background transfer sessions, as iOS names them when it relaunches or wakes +/// the app for their events +public enum BackgroundTransferSessions { + private static var bundleIdentifier: String { + Bundle.main.configurationValue(for: .bundleIdentifier) + } + + public static var uploadIdentifier: String { "\(bundleIdentifier).background" } + public static var cellularUploadIdentifier: String { "\(bundleIdentifier).background.cellular" } + public static var downloadIdentifier: String { "\(bundleIdentifier).background.download" } + + public static var uploadIdentifiers: Set { [uploadIdentifier, cellularUploadIdentifier] } + + /// Recreates the upload sessions, which is what lets iOS deliver the events it held for them + public static func activateUploadSessions() { + _ = BPURLSession.shared + } +} + /// URL session meant for upload tasks class BPURLSession { static let shared = BPURLSession() public let backgroundSession: URLSession public let backgroundCellularSession: URLSession - public let progressPublisher: PassthroughSubject<(String, Double), Never> + /// The emitting task and its bytes sent so far + public let progressPublisher: PassthroughSubject<(URLSessionTask, Int64), Never> public let completionPublisher: PassthroughSubject<(URLSessionTask, Error?), Never> private var cellularDataObserver: NSKeyValueObservation? private init() { - let progressPublisher = PassthroughSubject<(String, Double), Never>() + let progressPublisher = PassthroughSubject<(URLSessionTask, Int64), Never>() let completionPublisher = PassthroughSubject<(URLSessionTask, Error?), Never>() - let bundleIdentifier: String = Bundle.main.configurationValue(for: .bundleIdentifier) - let delegate = BPTaskUploadDelegate() - delegate.uploadProgressUpdated = { [progressPublisher] task, uploadProgress in - guard let relativePath = task.taskDescription else { return } - - progressPublisher.send((relativePath, uploadProgress)) + delegate.uploadProgressUpdated = { [progressPublisher] task, bytesSent in + progressPublisher.send((task, bytesSent)) } delegate.didFinishTask = { [completionPublisher] task, error in completionPublisher.send((task, error)) @@ -38,25 +54,33 @@ class BPURLSession { self.completionPublisher = completionPublisher let configuration = URLSessionConfiguration.background( - withIdentifier: "\(bundleIdentifier).background" + withIdentifier: BackgroundTransferSessions.uploadIdentifier ) configuration.allowsCellularAccess = false self.backgroundSession = URLSession( configuration: configuration, delegate: delegate, - delegateQueue: OperationQueue() + delegateQueue: Self.serialDelegateQueue() ) let configurationForCellular = URLSessionConfiguration.background( - withIdentifier: "\(bundleIdentifier).background.cellular" + withIdentifier: BackgroundTransferSessions.cellularUploadIdentifier ) configurationForCellular.allowsCellularAccess = true self.backgroundCellularSession = URLSession( configuration: configurationForCellular, delegate: delegate, - delegateQueue: OperationQueue() + delegateQueue: Self.serialDelegateQueue() ) } + + /// Serial, so callbacks arrive in order: "finished events" follows the last completion, + /// and a part's progress never lands after its completion + static func serialDelegateQueue() -> OperationQueue { + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 1 + return queue + } } diff --git a/Shared/Network/NetworkClient.swift b/Shared/Network/NetworkClient.swift index 45e571fca..b833110b2 100644 --- a/Shared/Network/NetworkClient.swift +++ b/Shared/Network/NetworkClient.swift @@ -139,10 +139,20 @@ public class NetworkClient: NetworkClientProtocol, BPLogger { var request = URLRequest(url: remoteURL) request.cachePolicy = .reloadIgnoringLocalCacheData request.httpMethod = HTTPMethod.put.rawValue - + + // .path only: presigned URLs carry AWS credentials in the query string. Self.logger.trace("[Request] PUT \(remoteURL.path)") - - _ = try await URLSession.shared.upload(for: request, from: data) + + let (responseData, response) = try await URLSession.shared.upload(for: request, from: data) + + // Cast the response to HTTPURLResponse to check the status code + if let httpResponse = response as? HTTPURLResponse { + if !(200...299).contains(httpResponse.statusCode) { + let errorMessage = String(data: responseData, encoding: .utf8) ?? "No error body" + // Typed like the rest of the client, so upload failures surface consistently + throw BookPlayerError.networkError("Upload failed (HTTP \(httpResponse.statusCode)): \(errorMessage)") + } + } } public func uploadTask( @@ -193,7 +203,11 @@ public class NetworkClient: NetworkClientProtocol, BPLogger { case 400...499: let error = try self.decoder.decode(ErrorResponse.self, from: data) if let code = error.error { - throw BookPlayerError.networkErrorWithCode(message: error.message, code: code) + throw BookPlayerError.networkErrorWithCode( + message: error.message, + code: code, + status: httpURLResponse.statusCode + ) } else { throw BookPlayerError.networkError(error.message) } @@ -265,13 +279,19 @@ public class NetworkClient: NetworkClientProtocol, BPLogger { if case .get = method, let parameters = parameters { - let queryItems = parameters.map({ - URLQueryItem( - name: $0.0, - value: "\($0.1)" - ) - }) - components.queryItems = queryItems + // Optionals stored as `Any` interpolate as `Optional("…")`, which is what the + // API had been receiving for every GET `uuid` (it silently failed the server's + // UUID check and fell back to a path lookup). Unwrap them, and drop absent + // values instead of sending the literal string "nil". + components.queryItems = parameters.compactMap { key, value in + guard let unwrapped = Self.unwrapOptional(value) else { return nil } + return URLQueryItem(name: key, value: "\(unwrapped)") + } + // URLComponents leaves `+` as is, and the API (Express/qs) decodes a literal `+` in + // a query value as a space: "A+B.mp3" arrived as "A B.mp3", and an S3 upload id + // carrying `+` would be reported as not found + components.percentEncodedQuery = components.percentEncodedQuery? + .replacingOccurrences(of: "+", with: "%2B") } guard let url = components.url else { @@ -280,6 +300,13 @@ public class NetworkClient: NetworkClientProtocol, BPLogger { return try buildURLRequest(url: url, method: method, parameters: parameters) } + + /// `nil` for a nil optional, the wrapped value for a non-nil one, the value itself otherwise. + private static func unwrapOptional(_ value: Any) -> Any? { + if case Optional.none = value { return nil } + if case Optional.some(let inner) = value { return inner } + return value + } } /// Default error message structure @@ -287,3 +314,27 @@ struct ErrorResponse: Decodable { let message: String let error: String? } + + +extension NetworkClientProtocol { + /// PUT a file streaming FROM DISK — for payloads (audiobooks) that must never be + /// materialized as one in-memory Data. Same error contract as `upload(_:remoteURL:)`. + /// A protocol extension (not a requirement) so the Sourcery mocks don't need regeneration. + public func upload( + fileURL: URL, + remoteURL: URL + ) async throws { + var request = URLRequest(url: remoteURL) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.httpMethod = HTTPMethod.put.rawValue + + let (responseData, response) = try await URLSession.shared.upload(for: request, fromFile: fileURL) + + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + let errorMessage = String(data: responseData, encoding: .utf8) ?? "No error body" + // Typed like the rest of the client, so upload failures surface consistently + throw BookPlayerError.networkError("Upload failed (HTTP \(httpResponse.statusCode)): \(errorMessage)") + } + } +} diff --git a/Shared/Services/Account/AccountService.swift b/Shared/Services/Account/AccountService.swift index f49d7d1f4..090705563 100644 --- a/Shared/Services/Account/AccountService.swift +++ b/Shared/Services/Account/AccountService.swift @@ -28,7 +28,7 @@ public enum SecondOnboardingError: Error { } public enum AccessLevel: String, CaseIterable, Identifiable { - case free, plus, pro + case free, plus, lite, pro public var id: String { rawValue } } @@ -59,6 +59,8 @@ public protocol AccountServiceProtocol { func hasAccount() -> Bool func hasSyncEnabled() -> Bool func hasPlusAccess() -> Bool + /// Whether the user holds, or ever held, a subscription they did not get refunded. + func hasEverSubscribed() -> Bool @discardableResult func createAccount(donationMade: Bool) -> Account @@ -74,7 +76,8 @@ public protocol AccountServiceProtocol { func getHardcodedSubscriptionOptions() -> [PricingModel] func getSubscriptionOptions() async throws -> [PricingModel] - + func getAccessLevel() -> AccessLevel + func subscribe(option: PricingModel) async throws -> Bool func restorePurchases() async throws -> CustomerInfo @@ -106,6 +109,8 @@ public protocol AccountServiceProtocol { @Observable public final class AccountService: AccountServiceProtocol { + let monthlyLiteSubscriptionId = "com.tortugapower.audiobookplayer.subscription.lite" + let yearlyLiteSubscriptionId = "com.tortugapower.audiobookplayer.subscription.lite.yearly" let monthlySubscriptionId = "com.tortugapower.audiobookplayer.subscription.pro" let yearlySubscriptionId = "com.tortugapower.audiobookplayer.subscription.pro.yearly" var dataManager: DataManager! @@ -175,7 +180,27 @@ public final class AccountService: AccountServiceProtocol { } public func hasSyncEnabled() -> Bool { - return Purchases.shared.cachedCustomerInfo?.entitlements.all["pro"]?.isActive == true + return Purchases.shared.cachedCustomerInfo?.entitlements.all["pro"]?.isActive == true || Purchases.shared.cachedCustomerInfo?.entitlements.all["lite"]?.isActive == true + } + + /// A fresh (network) RevenueCat read of the sync entitlement, for when the server rejects + /// the account while the cached info says otherwise. The account update it triggers + /// posts `.accountUpdate`, so an inactive result runs the usual lapse path. nil = the + /// fetch failed. + public func refreshSyncEntitlement() async -> Bool? { + do { + let customerInfo = try await Purchases.shared.customerInfo(fetchPolicy: .fetchCurrent) + // updateAccount works on the view context: main only, like every other caller + await MainActor.run { self.updateAccount(from: customerInfo) } + let entitlements = customerInfo.entitlements.all + return entitlements["pro"]?.isActive == true || entitlements["lite"]?.isActive == true + } catch { + return nil + } + } + + public func hasLiteEnabled() -> Bool { + return Purchases.shared.cachedCustomerInfo?.entitlements.all["lite"]?.isActive == true } public func hasPlusAccess() -> Bool { @@ -184,9 +209,9 @@ public final class AccountService: AccountServiceProtocol { } let entitlements = cachedInfo.entitlements.all - if entitlements["plus"]?.isActive == true || entitlements["pro"]?.isActive == true + || entitlements["lite"]?.isActive == true { return true } @@ -197,13 +222,62 @@ public final class AccountService: AccountServiceProtocol { { return false } + + if entitlements["lite"]?.isActive == false, + let subscriptionInfo = getSubscriptionInfo(from: cachedInfo), + subscriptionInfo.refundedAt != nil + { + return false + } return getAccount()?.donationMade == true } - private func getAccessLevel() -> AccessLevel { - if hasSyncEnabled() { + /// Whether the user holds, or ever held, a subscription they did not get refunded — the + /// "has paid at some point" half of streaming access, which outlives the subscription. + /// + /// Family-shared and sandbox rows would also qualify, since neither carries a refund date. + /// Neither is filtered: every product in `IAP-Configuration.storekit` is + /// `familyShareable: false`, and sandbox subscriptions only reach developers and QA + /// (purchases are disabled on TestFlight via `AppEnvironment.isPurchaseEnabled`). + /// + /// Reads `subscriptionsByProductIdentifier` directly rather than going through + /// `getSubscriptionInfo(from:)`: that helper breaks on the first `PricingOption` match, so it + /// answers about ONE subscription chosen by enum order, and would deny someone who had a + /// refunded pro alongside a legitimately lapsed lite. Reading the dictionary also covers a + /// legacy product id that predates the enum. + public func hasEverSubscribed() -> Bool { + guard let cachedInfo = Purchases.shared.cachedCustomerInfo else { return false } + + return Self.hasUnrefundedSubscription( + refundDates: cachedInfo.subscriptionsByProductIdentifier.values.map(\.refundedAt) + ) + } + + /// The streaming rule, taking plain values so the composition is testable: the inputs come + /// from RevenueCat and CoreData, neither of which a unit test can stand up. + static func resolveStreamingAccess( + isSignedIn: Bool, + hasPlusAccess: Bool, + hasEverSubscribed: Bool + ) -> Bool { + isSignedIn && (hasPlusAccess || hasEverSubscribed) + } + + /// The rule behind `hasEverSubscribed()`, taking the refund dates rather than the + /// subscriptions: `SubscriptionInfo`'s initializer is internal to RevenueCat, so a test + /// cannot build one. + static func hasUnrefundedSubscription(refundDates: [Date?]) -> Bool { + refundDates.contains { $0 == nil } + } + + public func getAccessLevel() -> AccessLevel { + // The pro entitlement is checked explicitly first: a user holding BOTH pro and lite + // (e.g. mid-crossgrade) must resolve to the higher tier, not fall through to lite. + if Purchases.shared.cachedCustomerInfo?.entitlements.all["pro"]?.isActive == true { return .pro + } else if hasLiteEnabled() { + return .lite } else if hasPlusAccess() { return .plus } else { @@ -322,6 +396,38 @@ public final class AccountService: AccountServiceProtocol { return options } + + public func getLiteSubscriptionOptions() async throws -> [PricingModel] { + let products = await Purchases.shared.products([yearlyLiteSubscriptionId, monthlyLiteSubscriptionId]) + + var options = [PricingModel]() + + if let product = products.first(where: { $0.productIdentifier == yearlyLiteSubscriptionId }) { + options.append( + PricingModel( + id: product.productIdentifier, + title: "\(product.localizedPriceString) \("yearly_title".localized)", + price: product.priceDecimalNumber.doubleValue + ) + ) + } + + if let product = products.first(where: { $0.productIdentifier == monthlyLiteSubscriptionId }) { + options.append( + PricingModel( + id: product.productIdentifier, + title: "\(product.localizedPriceString) \("monthly_title".localized)", + price: product.priceDecimalNumber.doubleValue + ) + ) + } + + if options.isEmpty { + throw AccountError.emptyProducts + } + + return options + } public func subscribe(option: PricingModel) async throws -> Bool { return try await subscribe(productId: option.id) @@ -518,3 +624,31 @@ public final class AccountService: AccountServiceProtocol { ) } } + + +extension AccountServiceProtocol { + /// Whether media-server streaming is available: signed in, AND has paid at some point — + /// broader than `hasSyncEnabled()` on the second half, on purpose. Streaming reaches the + /// user's own Jellyfin/AudiobookShelf and never our servers, so it survives a subscription + /// ending, while sync, S3 and the progress pull stay behind an active lite/pro subscription. + /// + /// `getAccountId()`, not `hasAccount()`: logout blanks the account's fields but leaves the + /// row, so `hasAccount()` stays true afterwards — `AccountServiceTests` asserts it is still + /// true even after `deleteAccount()`. Only the id going empty marks a signed-out user, and + /// without this check a tip alone reopened streaming after sign-out, since `donationMade` + /// deliberately outlives logout. + /// + /// Both remaining clauses are load-bearing: + /// - `hasPlusAccess()` — an active tier, or a one-time tip. A tip is not read separately: + /// RevenueCat attaches `plus` to non-subscription purchases, and that check short-circuits + /// before the refund handling, so `hasPlusAccess()` already answers for tippers. + /// - `hasEverSubscribed()` — a subscription that expired without being refunded, which no + /// entitlement reports any more. + public func hasStreamingEnabled() -> Bool { + return AccountService.resolveStreamingAccess( + isSignedIn: getAccountId() != nil, + hasPlusAccess: hasPlusAccess(), + hasEverSubscribed: hasEverSubscribed() + ) + } +} diff --git a/Shared/Services/Account/PricingModel.swift b/Shared/Services/Account/PricingModel.swift index 2f5a4818a..1be8ef4c6 100644 --- a/Shared/Services/Account/PricingModel.swift +++ b/Shared/Services/Account/PricingModel.swift @@ -46,6 +46,8 @@ public enum PricingOption: String, Identifiable, Codable, CaseIterable { case proMonthly = "com.tortugapower.audiobookplayer.subscription.pro" case proYearly = "com.tortugapower.audiobookplayer.subscription.pro.yearly" + case liteMonthly = "com.tortugapower.audiobookplayer.subscription.lite" + case liteYearly = "com.tortugapower.audiobookplayer.subscription.lite.yearly" case supportTier4 = "com.tortugapower.audiobookplayer.subscription.support.4" case supportTier6 = "com.tortugapower.audiobookplayer.subscription.support.6" case supportTier7 = "com.tortugapower.audiobookplayer.subscription.support.7" @@ -71,6 +73,10 @@ public enum PricingOption: String, Identifiable, Codable, CaseIterable { return "$8.99" case .supportTier10: return "$9.99" + case .liteMonthly: + return "$2.99" + case .liteYearly: + return "$29.99" } } @@ -92,6 +98,10 @@ public enum PricingOption: String, Identifiable, Codable, CaseIterable { return 8.99 case .supportTier10: return 9.99 + case .liteMonthly: + return 2.99 + case .liteYearly: + return 29.99 } } } diff --git a/Shared/Services/AudioMetadataService.swift b/Shared/Services/AudioMetadataService.swift index 8cc30c6c4..62aa86a25 100644 --- a/Shared/Services/AudioMetadataService.swift +++ b/Shared/Services/AudioMetadataService.swift @@ -9,7 +9,7 @@ import AVFoundation import CoreMedia -public struct ChapterMetadata { +public struct ChapterMetadata: Codable, Equatable, Hashable, Sendable { public let title: String public let start: TimeInterval public let duration: TimeInterval diff --git a/Shared/Services/ExternalProgress/ExternalProgressProviding.swift b/Shared/Services/ExternalProgress/ExternalProgressProviding.swift new file mode 100644 index 000000000..c61040045 --- /dev/null +++ b/Shared/Services/ExternalProgress/ExternalProgressProviding.swift @@ -0,0 +1,263 @@ +// +// ExternalProgressProviding.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +/// Where a media server says the user is in a book. +/// +/// Provider-neutral on purpose: `handleSyncFromExternalResource` and the resume prompt both +/// only ever needed the position, the date and the finished flag, and typing that to one +/// provider's item is what left AudiobookShelf out of the library refresh. +public struct ExternalPlaybackProgress: Equatable, Sendable { + public let currentTime: TimeInterval + public let lastPlayedDate: Date? + public let isFinished: Bool? + + public init(currentTime: TimeInterval, lastPlayedDate: Date?, isFinished: Bool? = nil) { + self.currentTime = currentTime + self.lastPlayedDate = lastPlayedDate + self.isFinished = isFinished + } +} + +/// Everything one refresh learns about an item from its own server. The progress is why the +/// pull exists; the chapters ride along because the SAME response already carries them and a +/// streamed item has no other source — nothing ever opens its file. +public struct ExternalItemSnapshot: Sendable { + public let progress: ExternalPlaybackProgress + /// Filled only when the batch was asked `includingChapters` — i.e. for the items that + /// still have none. An empty list is therefore NOT a statement that the server has no + /// chapters, which is why the ingest only ever ADDS (`storeChaptersIfEmpty`) and never + /// clears a list. + public let chapters: [ChapterMetadata] + + public init(progress: ExternalPlaybackProgress, chapters: [ChapterMetadata] = []) { + self.progress = progress + self.chapters = chapters + } +} + +extension [ExternalPlaybackProgress] { + /// The candidate worth prompting the user about, or nil when no server has anything newer. + /// + /// Two steps. A candidate qualifies only if it is ahead of the local state by more than + /// `threshold` — either a newer play date or a farther position — which keeps the prompt from + /// firing on the rounding drift of a book the user is actively listening to. Among the + /// qualifiers the NEWEST date wins, the same strictly-newer rule + /// `SyncService.handleSyncedLastPlayed` and `handleSyncFromExternalResource` already use for + /// our own cloud, with position breaking ties for servers that report no date. + /// + /// Lives on the collection rather than on the service: it reasons about these values and + /// nothing else, so a service instance has no say in the answer. + func promptable( + localTime: TimeInterval, + localDate: Date?, + threshold: TimeInterval = 15 + ) -> ExternalPlaybackProgress? { + let localDate = localDate ?? .distantPast + + return + filter { candidate in + let isDateNewer = (candidate.lastPlayedDate ?? .distantPast) > localDate.addingTimeInterval(threshold) + let isPositionFarther = candidate.currentTime > localTime + threshold + + return isDateNewer || isPositionFarther + } + .max { + ($0.lastPlayedDate ?? .distantPast, $0.currentTime) < ($1.lastPlayedDate ?? .distantPast, $1.currentTime) + } + } +} + +/// The list-refresh entry point `ListSyncRefreshService` drives. One method, so the refresh +/// service can be tested with a recording stub instead of providers, a keychain or a network. +public protocol ExternalProgressRefreshing: AnyObject { + /// Pull the positions of every media-server item at one library level (root when nil) and + /// fold them into the library. Gated to the sync entitlement inside. + func refreshItems(at relativePath: String?) async +} + +/// Reads one provider's playback position for a resource. +/// +/// `Sendable` so the service can fan the providers out concurrently: an item linked to two +/// servers asks both at once instead of letting a slow one delay the other. +public protocol ExternalProgressProviding: Sendable { + /// The position that resource's OWN server reports, or nil when no saved connection matches + /// its host — the server isn't configured on this device, so there is nothing to compare. + func progress(for resource: SimpleExternalResource) async throws -> ExternalPlaybackProgress? + + /// Positions for many resources at once, keyed by providerId. + /// + /// The resources may span SEVERAL servers of the same provider, so each is resolved to its + /// own connection and queried there — a batch is not one request. A resource whose host + /// resolves to nothing is simply absent from the result. + /// + /// `includingChapters` is asked for only the items that still need them. Chapters are + /// written once and never replaced, so carrying them on every refresh would pay for a + /// large payload forever — and a book whose server genuinely has none would keep asking. + func progress( + forBatch resources: [SimpleExternalResource], + includingChapters: Bool + ) async throws -> [String: ExternalItemSnapshot] +} + +extension ExternalProgressProviding { + /// A batch that never throws and never asks for nothing: a provider builds a connection + /// service and reads the keychain even for an empty list, so the caller's partitions can + /// be empty without paying for it, and one server being down can't fail the refresh. + func snapshots( + forBatch resources: [SimpleExternalResource], + includingChapters: Bool + ) async -> [String: ExternalItemSnapshot] { + guard !resources.isEmpty else { return [:] } + + return (try? await progress(forBatch: resources, includingChapters: includingChapters)) ?? [:] + } + + /// Groups resources by the connection that owns them, so a caller can query each server + /// with only its own ids. Shared by both adapters: the grouping rule is the resolution + /// contract, not provider-specific. + func grouped( + _ resources: [SimpleExternalResource], + by connections: [C] + ) -> [(connection: C, resources: [SimpleExternalResource])] { + var byConnectionIndex: [Int: [SimpleExternalResource]] = [:] + + for resource in resources { + guard + let connection = IntegrationHostResolver.connection(for: resource.hostId, in: connections), + let index = connections.firstIndex(where: { $0.stableHostId == connection.stableHostId }) + else { continue } + + byConnectionIndex[index, default: []].append(resource) + } + + return byConnectionIndex.map { (connections[$0.key], $0.value) } + } +} + +/// Stateless: the connection service is built per call rather than held. +/// +/// Holding one would save a keychain read, but it is `@MainActor`-isolated and therefore not +/// `Sendable`, which the concurrent fan-out needs. The cost that actually mattered — reading +/// the keychain on every playback start, including for plain local books — is gone anyway, +/// because the service only reaches a provider for an item that HAS media-server resources. +public struct JellyfinProgressProvider: ExternalProgressProviding { + public init() {} + + public func progress(for resource: SimpleExternalResource) async throws -> ExternalPlaybackProgress? { + let service = await JellyfinConnectionService() + await service.setup() + + // Pin the resource's own server: the active connection may be a different instance where + // this provider id doesn't exist, or worse, names another book. + guard + let connection = IntegrationHostResolver.connection( + for: resource.hostId, + in: await service.connections + ) + else { return nil } + + await service.useConnection(connection) + + guard let item = try await service.fetchItem(for: resource.providerId) else { return nil } + + return ExternalPlaybackProgress( + currentTime: TimeInterval(item.currentSeconds ?? 0), + lastPlayedDate: item.lastPlayedDate, + isFinished: item.isFinished + ) + } + + public func progress( + forBatch resources: [SimpleExternalResource], + includingChapters: Bool + ) async throws -> [String: ExternalItemSnapshot] { + let service = await JellyfinConnectionService() + await service.setup() + + var progress: [String: ExternalItemSnapshot] = [:] + + for group in grouped(resources, by: await service.connections) { + await service.useConnection(group.connection) + + let items = try await service.updateItemsFromJellyfin( + group.resources, + includingChapters: includingChapters + ) + for (providerId, item) in items { + progress[providerId] = ExternalItemSnapshot( + progress: ExternalPlaybackProgress( + currentTime: TimeInterval(item.currentSeconds ?? 0), + lastPlayedDate: item.lastPlayedDate, + isFinished: item.isFinished + ), + chapters: includingChapters ? item.chapters : [] + ) + } + } + + return progress + } +} + +/// Same shape as `JellyfinProgressProvider`, including why it holds no connection service. +public struct AudiobookShelfProgressProvider: ExternalProgressProviding { + public init() {} + + public func progress(for resource: SimpleExternalResource) async throws -> ExternalPlaybackProgress? { + let service = await AudiobookShelfConnectionService() + await service.setup() + + guard + let connection = IntegrationHostResolver.connection( + for: resource.hostId, + in: await service.connections + ) + else { return nil } + + await service.useConnection(connection) + + guard let item = try await service.fetchItem(for: resource.providerId) else { return nil } + + return ExternalPlaybackProgress( + currentTime: item.currentTime ?? 0, + lastPlayedDate: item.lastPlayedDate, + isFinished: item.isFinished + ) + } + + /// `includingChapters` changes nothing about the REQUEST here: `batch/get` returns expanded + /// media either way, so the chapters are already on the wire. It only decides whether they + /// are carried forward, keeping the flag's meaning the same across providers. + public func progress( + forBatch resources: [SimpleExternalResource], + includingChapters: Bool + ) async throws -> [String: ExternalItemSnapshot] { + let service = await AudiobookShelfConnectionService() + await service.setup() + + var progress: [String: ExternalItemSnapshot] = [:] + + for group in grouped(resources, by: await service.connections) { + await service.useConnection(group.connection) + + for item in try await service.fetchItems(ids: group.resources.map(\.providerId)) { + progress[item.id] = ExternalItemSnapshot( + progress: ExternalPlaybackProgress( + currentTime: item.currentTime ?? 0, + lastPlayedDate: item.lastPlayedDate, + isFinished: item.isFinished + ), + chapters: includingChapters ? item.chapters : [] + ) + } + } + + return progress + } +} diff --git a/Shared/Services/ExternalProgress/ExternalProgressService.swift b/Shared/Services/ExternalProgress/ExternalProgressService.swift new file mode 100644 index 000000000..cc32a1d73 --- /dev/null +++ b/Shared/Services/ExternalProgress/ExternalProgressService.swift @@ -0,0 +1,223 @@ +// +// ExternalProgressService.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Combine +import Foundation + +/// Pulls the position a book's media servers report, so playback can offer to resume where +/// another device left off. +/// +/// This is the inbound half of media-server progress sync. The outbound half already lives in +/// a service — `libraryService.progressUpdatePublisher` feeds `SyncService.scheduleMetadataUpdate` +/// and the `externalUpdate` queue — while the pull used to live on `ItemListViewModel`, reached +/// through a delegate that exists for an unrelated question. That made a UI object's identity +/// decide whether the feature ran at all: CarPlay claims the same delegate slot and answered +/// with an empty implementation, so a car-only session silently never pulled. +/// +/// The two directions are gated differently on purpose. The push (`.externalUpdate`) runs on +/// every tier: it writes the user's position to the user's OWN server. The pull — both the +/// on-play prompt and the list refresh — is the cross-device sync feature, so it needs the +/// sync entitlement (`lite` or `pro`, `hasSyncEnabled()`), read live on every call. +public final class ExternalProgressService: ExternalProgressRefreshing { + /// A remote position worth offering the user, published rather than written into UI state so + /// both presentations — the SwiftUI alert and CarPlay's own — can consume the one decision. + public let promptablePositionPublisher = PassthroughSubject() + + private var libraryService: LibraryServiceProtocol! + private var accountService: AccountServiceProtocol! + private var providers: [ExternalResource.ProviderName: ExternalProgressProviding] = [:] + + /// Guards the single in-flight refresh and the uuid it was started for. Non-isolated so the + /// concurrent fan-out below doesn't have to hop to an actor to read them. + private let stateLock = NSLock() + private var refreshTask: Task? + private var requestedUuid: String? + private var disposeBag = Set() + private var notificationCenter: NotificationCenter = .default + + public init() {} + + public func setup( + libraryService: LibraryServiceProtocol, + accountService: AccountServiceProtocol, + providers: [ExternalResource.ProviderName: ExternalProgressProviding] = [ + .jellyfin: JellyfinProgressProvider(), + .audiobookshelf: AudiobookShelfProgressProvider(), + ], + notificationCenter: NotificationCenter = .default + ) { + self.libraryService = libraryService + self.accountService = accountService + self.providers = providers + self.notificationCenter = notificationCenter + + bindObservers() + } + + /// Self-subscribed rather than called by the player, so nothing about which UI is attached + /// can decide whether a refresh happens — the point of moving this off a view model. + /// `.bookPlayed` is posted by PlayerManager for every playback start, phone or CarPlay. + private func bindObservers() { + notificationCenter.publisher(for: .bookPlayed) + .compactMap { $0.userInfo?["book"] as? PlayableItem } + .sink { [weak self] item in + self?.refreshProgress(for: item) + } + .store(in: &disposeBag) + + notificationCenter.publisher(for: .logout) + .sink { [weak self] _ in + self?.teardown() + } + .store(in: &disposeBag) + + // A downgrade mid-refresh must not still publish a prompt: the entitlement is checked + // at the start of every pull, and here for the one already in flight. + notificationCenter.publisher(for: .accountUpdate) + .sink { [weak self] _ in + guard let self, !self.accountService.hasSyncEnabled() else { return } + self.teardown() + } + .store(in: &disposeBag) + } + + /// Ask every media server this item is linked to where the user is, and publish the answer + /// if it is far enough ahead of the local position to be worth a prompt. + /// + /// Replaces any refresh already running: only the item that is playing NOW can produce a + /// prompt. The previous design keyed tasks by uuid, so a slow answer for a book the user had + /// already moved on from could still raise a prompt carrying that book's position — and the + /// alert's "resume" then seeks whatever is playing to a timestamp from another book. + public func refreshProgress(for item: PlayableItem) { + guard accountService.hasSyncEnabled() else { return } + + let uuid = item.uuid + let localTime = item.currentTime + let localDate = item.lastPlayDate + + stateLock.lock() + defer { stateLock.unlock() } + + refreshTask?.cancel() + requestedUuid = uuid + // Created under the same lock it is stored under. Creating it outside let a second call + // interleave between creation and storage, leaving refreshTask pointing at the task it + // had just cancelled while the live one ran unreferenced — correct output only by luck + // of the isStillRequested check, and a teardown that would miss the live task. Holding + // the lock here is safe: the body touches it only after its first await. + refreshTask = Task { [weak self] in + guard let self else { return } + + let resources = (self.libraryService.findResources(for: uuid) ?? []).mediaServerResources + // No media server for this book: nothing to ask, and in particular no keychain read. + guard !resources.isEmpty else { return } + + let candidates = await self.fetchConcurrently(for: resources) + + guard + !Task.isCancelled, + self.isStillRequested(uuid), + let position = candidates.promptable(localTime: localTime, localDate: localDate) + else { return } + + self.promptablePositionPublisher.send(position) + } + } + + /// Refresh every media-server item at one library level, so the list shows what the user's + /// other devices did without waiting for them to open each book. + /// + /// Driven by `ListSyncRefreshService.syncList` right after the cloud sync of the same level, + /// on the links that sync just reconciled. Resource-first: one background query returns only + /// the media-server rows, so the main thread does nothing here but the ingest. Every provider + /// is asked in parallel and each folds its own answers in — AudiobookShelf items refresh + /// alongside Jellyfin ones, which they never did while the ingest was typed to a Jellyfin item. + public func refreshItems(at relativePath: String?) async { + guard accountService.hasSyncEnabled() else { return } + + let resources = await libraryService.findMediaServerResources(at: relativePath) + guard !resources.isEmpty else { return } + + let byProvider = Dictionary(grouping: resources) { $0.providerName } + + await withTaskGroup(of: (String, [String: ExternalItemSnapshot]).self) { group in + for (providerName, providerResources) in byProvider { + guard + let name = ExternalResource.ProviderName(rawValue: providerName), + let provider = providers[name] + else { continue } + + group.addTask { + // Only the items that still lack chapters pay for them. In the steady state that + // set is empty and this is exactly the progress-only refresh it has always been; + // a book whose server has none stays in the small set instead of making every + // refresh carry the whole level's chapters forever. + let needing = providerResources.filter(\.needsChapters) + let rest = providerResources.filter { !$0.needsChapters } + + async let withChapters = provider.snapshots(forBatch: needing, includingChapters: true) + async let withoutChapters = provider.snapshots(forBatch: rest, includingChapters: false) + + // Disjoint by construction, so the tie-break only fires when one providerId is + // linked twice at this level — and then the answer carrying chapters is the one + // worth keeping. + return (providerName, await withChapters.merging(withoutChapters) { current, _ in current }) + } + } + + for await (providerName, snapshots) in group where !snapshots.isEmpty { + await libraryService.handleSyncFromExternalResource( + providerName: providerName, + snapshotsByProviderId: snapshots + ) + } + } + } + + /// Cancels any refresh in flight. Called on logout, the same lifecycle hook SyncService uses. + public func teardown() { + stateLock.lock() + refreshTask?.cancel() + refreshTask = nil + requestedUuid = nil + stateLock.unlock() + } + + /// One task group so a slow or unreachable server delays nobody, and a thrown error takes + /// only its own provider out of the running. + private func fetchConcurrently( + for resources: [SimpleExternalResource] + ) async -> [ExternalPlaybackProgress] { + await withTaskGroup(of: ExternalPlaybackProgress?.self) { group in + for resource in resources { + guard + let name = ExternalResource.ProviderName(rawValue: resource.providerName), + let provider = providers[name] + else { continue } + + group.addTask { + try? await provider.progress(for: resource) + } + } + + var candidates: [ExternalPlaybackProgress] = [] + for await candidate in group { + if let candidate { + candidates.append(candidate) + } + } + return candidates + } + } + + private func isStillRequested(_ uuid: String) -> Bool { + stateLock.lock() + defer { stateLock.unlock() } + return requestedUuid == uuid + } + +} diff --git a/Shared/Services/LibraryService+Sync.swift b/Shared/Services/LibraryService+Sync.swift index 4ae6f19eb..af7546c78 100644 --- a/Shared/Services/LibraryService+Sync.swift +++ b/Shared/Services/LibraryService+Sync.swift @@ -15,8 +15,6 @@ public protocol LibrarySyncProtocol { var metadataUpdatePublisher: AnyPublisher<[String: Any], Never> { get } var progressUpdatePublisher: AnyPublisher<[String: Any], Never> { get } - /// Fetch all the stored items in the library that are not in the remote identifiers array - func getItemsToSync(remoteIdentifiers: [String]) async -> [SyncableItem]? /// Update local items with synced info func updateInfo(for itemsDict: [String: SyncableItem], parentFolder: String?) async /// Update single local item with synced info @@ -48,6 +46,32 @@ public protocol LibrarySyncProtocol { func getBookmarks(of type: BookmarkType, relativePath: String) -> [SimpleBookmark]? /// Store new synced bookmark func addBookmark(from bookmark: SimpleBookmark) async + + /// Item uuid + external resources as lightweight values, fetched on the context's queue + func getItemResourcesSnapshot(for relativePath: String) -> (uuid: String, resources: [SyncableExternalResource])? + + func updateExternalResource(for item: SyncableExternalResource) async + + /// The item's current path, read on a background context (an upload in the background + /// finds its book's file by uuid after the user moved it) + func fetchRelativePath(forUuid uuid: String) async -> String? + + /// The item as the server would register it, found by uuid on a background context: an + /// upload the server no longer recognizes is re-registered from the book's CURRENT state + /// (a stale path would make the server move another item into place) + func fetchSyncableItem(forUuid uuid: String) async -> SyncableItem? + + /// Every real uuid in the library, read on a background context: the missing-items pass + /// asks the server about all of them. nil when the read failed (never "an empty library") + func fetchAllUuids() async -> [String]? + + /// The items with these uuids as the server would register them, parents before children, + /// read on a background context. nil when a read failed (never "fewer items") + func getSyncableItems(forUuids uuids: [String]) async -> [SyncableItem]? + + /// Every user bookmark of the items at these paths, grouped by path, read on a background + /// context in batches: a first sync registers the whole library + func getUserBookmarks(forItemsAt relativePaths: [String]) async -> [String: [SimpleBookmark]] } extension LibraryService: LibrarySyncProtocol { @@ -82,6 +106,128 @@ extension LibraryService: LibrarySyncProtocol { } } + public func fetchRelativePath(forUuid uuid: String) async -> String? { + guard Constants.isRealUuid(uuid) else { return nil } + return await withCheckedContinuation { continuation in + let context = dataManager.getBackgroundContext() + context.perform { [context] in + let fetchRequest = NSFetchRequest(entityName: "LibraryItem") + fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LibraryItem.uuid), uuid) + fetchRequest.propertiesToFetch = [#keyPath(LibraryItem.relativePath)] + fetchRequest.resultType = .dictionaryResultType + fetchRequest.fetchLimit = 1 + + do { + let relativePath = try context.fetch(fetchRequest).first?[#keyPath(LibraryItem.relativePath)] as? String + continuation.resume(returning: relativePath) + } catch { + Self.logger.error("Failed to look up the item \(uuid): \(error)") + continuation.resume(returning: nil) + } + } + } + } + + public func fetchSyncableItem(forUuid uuid: String) async -> SyncableItem? { + guard Constants.isRealUuid(uuid) else { return nil } + return await withCheckedContinuation { continuation in + let context = dataManager.getBackgroundContext() + context.perform { [unowned self, context] in + let fetchRequest = NSFetchRequest(entityName: "LibraryItem") + fetchRequest.propertiesToFetch = SyncableItem.fetchRequestProperties + fetchRequest.resultType = .dictionaryResultType + fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LibraryItem.uuid), uuid) + fetchRequest.fetchLimit = 1 + + let results = try? context.fetch(fetchRequest) as? [[String: Any]] + continuation.resume(returning: parseSyncableItems(from: results, context: context)?.first) + } + } + } + + public func fetchAllUuids() async -> [String]? { + return await withCheckedContinuation { continuation in + let context = dataManager.getBackgroundContext() + context.perform { [context] in + let fetchRequest = NSFetchRequest(entityName: "LibraryItem") + fetchRequest.propertiesToFetch = [#keyPath(LibraryItem.uuid)] + fetchRequest.resultType = .dictionaryResultType + + do { + let uuids = try context.fetch(fetchRequest).compactMap { $0[#keyPath(LibraryItem.uuid)] as? String } + continuation.resume(returning: uuids.filter(Constants.isRealUuid)) + } catch { + Self.logger.error("Failed to read the library's uuids: \(error)") + continuation.resume(returning: nil) + } + } + } + } + + public func getSyncableItems(forUuids uuids: [String]) async -> [SyncableItem]? { + guard !uuids.isEmpty else { return [] } + return await withCheckedContinuation { continuation in + let context = dataManager.getBackgroundContext() + context.perform { [unowned self, context] in + var items = [SyncableItem]() + // A few hundred per IN list keeps each fetch well under SQLite's bound-variable limit + for start in stride(from: 0, to: uuids.count, by: 500) { + let fetchRequest = NSFetchRequest(entityName: "LibraryItem") + fetchRequest.propertiesToFetch = SyncableItem.fetchRequestProperties + fetchRequest.resultType = .dictionaryResultType + fetchRequest.predicate = NSPredicate( + format: "%K IN %@", + #keyPath(LibraryItem.uuid), + Array(uuids[start.. [String: [SimpleBookmark]] { + guard !relativePaths.isEmpty else { return [:] } + return await withCheckedContinuation { continuation in + let context = dataManager.getBackgroundContext() + context.perform { [unowned self, context] in + var bookmarks = [String: [SimpleBookmark]]() + for start in stride(from: 0, to: relativePaths.count, by: 500) { + let fetchRequest = NSFetchRequest(entityName: "Bookmark") + fetchRequest.propertiesToFetch = SimpleBookmark.fetchRequestProperties + fetchRequest.resultType = .dictionaryResultType + fetchRequest.predicate = NSPredicate( + format: "%K IN %@ && type == %d", + #keyPath(Bookmark.item.relativePath), + Array(relativePaths[start.. Double? { return await withCheckedContinuation { continuation in let context = dataManager.getBackgroundContext() @@ -169,11 +315,100 @@ extension LibraryService: LibrarySyncProtocol { if !item.uuid.isEmpty { storedItem.uuid = item.uuid } + + if let remoteExternalResources = item.externalResources { + // Resource identity is the (providerName, providerId) PAIR — the contract + // everywhere else in this feature (setExternalResource dedup, findResource + // scoping): the same providerId can legitimately exist under BOTH providers, + // so diffing on providerId alone mis-reconciles cross-provider collisions. + struct ResourceKey: Hashable { + let providerName: String + let providerId: String + } + let remoteByKey = Dictionary( + remoteExternalResources.map { + (ResourceKey(providerName: $0.providerName, providerId: $0.providerId), $0) + }, + uniquingKeysWith: { first, _ in first } + ) + let localKeys = Set(storedItem.resourcesArray.map { + ResourceKey(providerName: $0.providerName, providerId: $0.providerId) + }) + let keysToAdd = Set(remoteByKey.keys).subtracting(localKeys) + + for localResource in storedItem.resourcesArray { + let key = ResourceKey( + providerName: localResource.providerName, + providerId: localResource.providerId + ) + if let remoteData = remoteByKey[key] { + if remoteData.syncStatus != localResource.syncStatus { + localResource.syncStatus = remoteData.syncStatus + } + } else if localResource.syncStatus != ExternalResource.SyncStatus.notSynced.rawValue { + // Only reconcile-away resources the server has SEEN: a locally-created link + // whose upload task hasn't run yet is legitimately absent from the server + // payload, and deleting it here loses the link before it ever syncs. + storedItem.removeFromExternalResources(localResource) + context.delete(localResource) + } + } + + for keyToAdd in keysToAdd { + guard let remoteData = remoteByKey[keyToAdd] else { + continue + } + + let resourceEntity = NSEntityDescription.entity(forEntityName: "ExternalResource", in: context)! + let external = ExternalResource(entity: resourceEntity, insertInto: context) + + external.providerId = remoteData.providerId + external.providerName = remoteData.providerName + external.syncStatus = remoteData.syncStatus + external.lastSyncedAt = remoteData.lastSyncedAt + external.processedFile = remoteData.processedFile + // The cross-device server identity — without it IntegrationHostResolver returns nil + // and a synced-down resource can never resolve to a saved connection on this device. + external.hostId = remoteData.hostId + + external.libraryItem = storedItem + storedItem.addToExternalResources(external) + } + } + + if let allExternalItems = self.findResourceEntities(for: storedItem.uuid, context: context), + allExternalItems.count > storedItem.resourcesArray.count { + let allSet = Set(allExternalItems) + let keepSet = Set(storedItem.resourcesArray) + + // 2. Find the difference (things in 'all' but NOT in 'keep') + let toDelete = allSet.subtracting(keepSet) + + // 3. Delete them from the context + toDelete.forEach { resource in + context.delete(resource) + } + } if shouldSaveContext { dataManager.saveSyncContext(context) } } + + public func updateExternalResource(for item: SyncableExternalResource) async { + return await withCheckedContinuation { continuation in + let context = dataManager.getBackgroundContext() + context.perform { [unowned self, context] in + let externalResource = self.findResourceEntity(for: item.providerId, providerName: item.providerName, context: context) + if let externalResource { + externalResource.syncStatus = item.syncStatus + externalResource.processedFile = item.processedFile + dataManager.saveSyncContext(context) + } + continuation.resume() + } + } + } public func storeNewItems(from itemsDict: [String: SyncableItem], parentFolder: String?) async { await withCheckedContinuation { continuation in @@ -209,6 +444,23 @@ extension LibraryService: LibrarySyncProtocol { syncItem: item, context: context ) + + if let externalResources = item.externalResources, externalResources.count > 0 { + for externalResource in externalResources { + let resourceEntity = NSEntityDescription.entity(forEntityName: "ExternalResource", in: context)! + let external = ExternalResource(entity: resourceEntity, insertInto: context) + + external.providerId = externalResource.providerId + external.providerName = externalResource.providerName + external.syncStatus = externalResource.syncStatus + external.lastSyncedAt = externalResource.lastSyncedAt + external.processedFile = externalResource.processedFile + external.hostId = externalResource.hostId + + external.libraryItem = newBook + newBook.addToExternalResources(external) + } + } if let relativePath = parentFolder, let folder = getItem(with: relativePath, context: context) as? Folder @@ -258,32 +510,6 @@ extension LibraryService: LibrarySyncProtocol { } } - public func getItemsToSync(remoteIdentifiers: [String]) async -> [SyncableItem]? { - return await withCheckedContinuation { continuation in - let context = dataManager.getBackgroundContext() - context.perform { [unowned self, context] in - let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "LibraryItem") - fetchRequest.propertiesToFetch = SyncableItem.fetchRequestProperties - fetchRequest.resultType = .dictionaryResultType - fetchRequest.predicate = NSPredicate( - format: "NOT (%K IN %@)", - #keyPath(LibraryItem.relativePath), - remoteIdentifiers - ) - let sort = NSSortDescriptor( - key: #keyPath(LibraryItem.relativePath), - ascending: true, - selector: #selector(NSString.localizedStandardCompare(_:)) - ) - fetchRequest.sortDescriptors = [sort] - - let results = try? context.fetch(fetchRequest) as? [[String: Any]] - - continuation.resume(returning: parseSyncableItems(from: results)) - } - } - } - public func getAllNestedItems(inside relativePath: String) -> [SyncableItem]? { let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "LibraryItem") fetchRequest.propertiesToFetch = SyncableItem.fetchRequestProperties @@ -294,12 +520,22 @@ extension LibraryService: LibrarySyncProtocol { relativePath ) - let results = try? self.dataManager.getBackgroundContext().fetch(fetchRequest) as? [[String: Any]] + let context = self.dataManager.getBackgroundContext() + var items: [SyncableItem]? + context.performAndWait { + let results = try? context.fetch(fetchRequest) as? [[String: Any]] + items = parseSyncableItems(from: results, context: context) + } - return parseSyncableItems(from: results) + return items } - func parseSyncableItems(from results: [[String: Any]]?) -> [SyncableItem]? { + func parseSyncableItems(from results: [[String: Any]]?, context: NSManagedObjectContext) -> [SyncableItem]? { + // One grouped fetch for all rows — a per-item fetch here is an N+1 on the sync path. + let resourcesByUuid = findResourcesGrouped( + forUuids: results?.compactMap { $0["uuid"] as? String } ?? [], + context: context + ) return results?.compactMap({ dictionary -> SyncableItem? in guard let uuid = dictionary["uuid"] as? String, @@ -322,6 +558,8 @@ extension LibraryService: LibrarySyncProtocol { if let lastPlayDate = dictionary["lastPlayDate"] as? Date { lastPlayDateTimestamp = lastPlayDate.timeIntervalSince1970 } + + let externalResources = resourcesByUuid[uuid] return SyncableItem( relativePath: relativePath, @@ -338,7 +576,17 @@ extension LibraryService: LibrarySyncProtocol { orderRank: orderRank, lastPlayDateTimestamp: lastPlayDateTimestamp, type: type, - uuid: uuid + uuid: uuid, + externalResources: externalResources?.map({ + SyncableExternalResource( + providerName: $0.providerName, + providerId: $0.providerId, + syncStatus: $0.syncStatus, + lastSyncedAt: $0.lastSyncedAt, + processedFile: $0.processedFile, + hostId: $0.hostId + ) + }) ) }) } @@ -372,6 +620,46 @@ extension LibraryService: LibrarySyncProtocol { } } } + + public func getItemResourcesSnapshot(for relativePath: String) -> (uuid: String, resources: [SyncableExternalResource])? { + let context = self.dataManager.getBackgroundContext() + + var snapshot: (uuid: String, resources: [SyncableExternalResource])? + context.performAndWait { + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LibraryItem.relativePath), relativePath) + fetchRequest.fetchLimit = 1 + + guard let item = try? context.fetch(fetchRequest).first else { return } + + let resources = item.resourcesArray.map { + SyncableExternalResource( + providerName: $0.providerName, + providerId: $0.providerId, + syncStatus: $0.syncStatus, + lastSyncedAt: $0.lastSyncedAt, + processedFile: $0.processedFile, + hostId: $0.hostId + ) + } + + snapshot = (uuid: item.uuid, resources: resources) + } + + return snapshot + } + + func getItemReference(with relativePath: String, context: NSManagedObjectContext) -> LibraryItem? { + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LibraryItem.relativePath), relativePath) + fetchRequest.fetchLimit = 1 + fetchRequest.propertiesToFetch = [ + #keyPath(LibraryItem.relativePath), + #keyPath(LibraryItem.originalFileName), + ] + + return try? context.fetch(fetchRequest).first + } private func createBackup( for item: SimpleLibraryItem, diff --git a/Shared/Services/LibraryService.swift b/Shared/Services/LibraryService.swift index 04364ad69..7870b01fb 100644 --- a/Shared/Services/LibraryService.swift +++ b/Shared/Services/LibraryService.swift @@ -11,6 +11,11 @@ import Combine import CoreData import Foundation +public enum ImportSource { + case local(files: [URL]) + case external(files: [SimpleExternalResource]) +} + /// sourcery: AutoMockable public protocol LibraryServiceProtocol: AnyObject { /// Metadata publisher that collects changes during 10 seconds before normalizing the payload @@ -56,6 +61,7 @@ public protocol LibraryServiceProtocol: AnyObject { func findBooks(containing fileURL: URL) -> [Book]? /// Fetch a single item with properties loaded func getSimpleItem(with relativePath: String) -> SimpleLibraryItem? + func getSimpleItem(for uuid: String) -> SimpleLibraryItem? /// Get items not included in a specific set func getItems(notIn relativePaths: [String], parentFolder: String?) -> [SimpleLibraryItem]? /// Fetch a property from a stored library item @@ -90,6 +96,10 @@ public protocol LibraryServiceProtocol: AnyObject { func createBook(from url: URL) async -> Book /// Load metadata chapters if needed func loadChaptersIfNeeded(relativePath: String, asset: AVAsset) async + /// Store chapters a media server reported for an item whose file is never opened here. + /// Same empty-guard as `loadChaptersIfNeeded(relativePath:asset:)`: an existing list is + /// never replaced, so this can run on every refresh without fighting the parsed one. + func storeChaptersIfNeeded(relativePath: String, chapters: [ChapterMetadata]) async /// Re-parse chapters from the file with our manual parsers, replacing the stored list only /// when more chapters are found. Returns the new chapter count, or nil if nothing changed. func reloadChapters(relativePath: String) async -> Int? @@ -162,11 +172,40 @@ public protocol LibraryServiceProtocol: AnyObject { func setHardcoverBook(_ hardcoverBook: SimpleHardcoverBook?, for relativePath: String) async /// Get hardcover book for an item func getHardcoverBook(for relativePath: String) async -> SimpleHardcoverBook? + /// Create an external resource linking the item (by uuid) to a provider's resource. + /// Returns the syncable representation to upload, or nil if it already exists or the item is missing. + func setExternalResource(providerName: String, providerId: String, for uuid: String) async -> SyncableExternalResource? + /// Remove the external resource of the given provider from the item (by uuid). + /// Returns the deleted resource's providerId, or nil if there was nothing to delete. + func removeExternalResource(providerName: String, for uuid: String) async -> String? + /// Returns the item's external resources as lightweight values. + func getExternalResources(for relativePath: String) async -> [SimpleExternalResource] + + /// Snapshot, not a managed object: services never hand NSManagedObjects out (repo invariant). + func findResource(for providerId: String, providerName: String?) -> SimpleExternalResource? + + func findResources(for uuid: String) -> [SimpleExternalResource]? + + /// Media-server links of every item at one library level (root when nil), the way the list + /// refresh needs them: one background fetch of the small resource rows, filtered to media + /// servers in the predicate, with no item snapshots built. Direct children only, like the + /// list itself — a book inside a subfolder belongs to that folder's level. + func findMediaServerResources(at relativePath: String?) async -> [SimpleExternalResource] + + // fromResources (not from:): overloading insertItems(from: [URL]) collides in the + // Sourcery-generated mock property names. + @MainActor func insertItems(fromResources resources: [SimpleExternalResource]) async -> [SimpleLibraryItem] + + /// Fold positions reported by a provider's servers into the local rows. + @MainActor func handleSyncFromExternalResource( + providerName: String, + snapshotsByProviderId: [String: ExternalItemSnapshot] + ) } // swiftlint:disable force_cast @Observable -public final class LibraryService: LibraryServiceProtocol, @unchecked Sendable { +public final class LibraryService: LibraryServiceProtocol, BPLogger, @unchecked Sendable { var dataManager: DataManager! var audioMetadataService: AudioMetadataServiceProtocol! /// Sticky-sort preference resolver. Injected after construction to break the @@ -279,18 +318,6 @@ public final class LibraryService: LibraryServiceProtocol, @unchecked Sendable { } } - func getItemReference(with relativePath: String, context: NSManagedObjectContext) -> LibraryItem? { - let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() - fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LibraryItem.relativePath), relativePath) - fetchRequest.fetchLimit = 1 - fetchRequest.propertiesToFetch = [ - #keyPath(LibraryItem.relativePath), - #keyPath(LibraryItem.originalFileName), - ] - - return try? context.fetch(fetchRequest).first - } - public func getItemReference(with relativePath: String) -> LibraryItem? { return getItemReference(with: relativePath, context: dataManager.getContext()) } @@ -401,7 +428,21 @@ public final class LibraryService: LibraryServiceProtocol, @unchecked Sendable { return fetchRequest } + /// One grouped fetch for every row's external resources — a per-row fetch here is an + /// N+1 on the main library list path. + func findResourcesGrouped(forUuids uuids: [String], context: NSManagedObjectContext) -> [String: [ExternalResource]] { + guard !uuids.isEmpty else { return [:] } + let fetch: NSFetchRequest = ExternalResource.fetchRequest() + fetch.predicate = NSPredicate(format: "%K IN %@", #keyPath(ExternalResource.libraryItem.uuid), uuids) + guard let resources = try? context.fetch(fetch) else { return [:] } + return Dictionary(grouping: resources) { $0.libraryItem?.uuid ?? "" } + } + func parseFetchedItems(from results: [[String: Any]]?, context: NSManagedObjectContext) -> [SimpleLibraryItem]? { + let resourcesByUuid = findResourcesGrouped( + forUuids: results?.compactMap { $0["uuid"] as? String } ?? [], + context: context + ) return results?.compactMap({ [weak self] dictionary -> SimpleLibraryItem? in guard let uuid = dictionary["uuid"] as? String, @@ -424,6 +465,8 @@ public final class LibraryService: LibraryServiceProtocol, @unchecked Sendable { self?.rebuildFolderDetails(relativePath, context: context) } + let externalResources = resourcesByUuid[uuid] + return SimpleLibraryItem( title: title, details: dictionary["details"] as? String ?? "", @@ -441,6 +484,7 @@ public final class LibraryService: LibraryServiceProtocol, @unchecked Sendable { lastPlayDate: dictionary["lastPlayDate"] as? Date, type: type, uuid: uuid, + externalResources: externalResources?.map({ SimpleExternalResource(from: $0, ignoreLibraryItem: true) }) ) }) } @@ -576,7 +620,9 @@ extension LibraryService { return nil } - return SimpleLibraryItem(from: item) + return SimpleLibraryItem( + from: item, + ) } public func getLibraryCurrentTheme() -> SimpleTheme? { @@ -999,6 +1045,22 @@ extension LibraryService { } } + /// The media-server rule, in one place: chapters are only ever ADDED. A list the parser + /// produced for a downloaded copy is never replaced by a later server refresh, and an empty + /// answer never clears one. + /// - Returns: whether anything was written, so a caller can skip a pointless save. + @discardableResult + private func storeChaptersIfEmpty( + _ chapters: [ChapterMetadata], + for book: Book, + context: NSManagedObjectContext + ) -> Bool { + guard !chapters.isEmpty, book.chapters?.count == 0 else { return false } + + storeChapters(chapters, for: book, context: context) + return true + } + /// Overload for backwards compatibility when we need to query by relativePath private func storeChapters(_ chapters: [ChapterMetadata], for relativePath: String, context: NSManagedObjectContext) { guard let book = getItem(with: relativePath, context: context) as? Book else { @@ -1282,6 +1344,52 @@ extension LibraryService { try self.delete(items, mode: .deep, context: context) } + + @MainActor + @discardableResult + public func insertItems(fromResources resources: [SimpleExternalResource]) async -> [SimpleLibraryItem] { + // Phase 2: Create CoreData entities on the main thread using pre-extracted data + let library = getLibraryReference() + var processedFiles = [SimpleLibraryItem]() + var nextOrderRank = getNextOrderRank(in: nil) + for resource in resources { + // libraryItem is optional by construction (ignoreLibraryItem paths) — a resource + // without one cannot become a book row; skip it instead of crashing. + guard let simpleItem = resource.libraryItem else { continue } + // Idempotent import: the (providerName, providerId) pair IS the book's identity + // on its server, so re-importing must reuse the existing row — inserting again + // creates a twin sharing the same relativePath, the library's de-facto primary + // key, which has NO store-level uniqueness constraint. Mirrors the file flow's + // hasExistingBook guard in ImportOperation and setExternalResource's dedup. + guard findResource(for: resource.providerId, providerName: resource.providerName) == nil else { + Self.logger.info( + "Virtual import skipped: \(resource.providerName)/\(resource.providerId) is already in the library" + ) + continue + } + // Belt-and-suspenders: an unrelated row occupying the synthesized path (e.g. a + // file literally named "-") must not gain a twin either. + let plannedRelativePath = "\(resource.providerId)-\(simpleItem.originalFileName)" + guard getItemReference(with: plannedRelativePath, context: dataManager.getContext()) == nil else { + Self.logger.warning( + "Virtual import skipped: a row already exists at \(plannedRelativePath)" + ) + continue + } + let libraryItem: LibraryItem + let book = await createExternalBook(simpleItem: simpleItem, externalResource: resource) + libraryItem = book + libraryItem.orderRank = nextOrderRank + nextOrderRank += 1 + + library.addToItems(libraryItem) + processedFiles.append(SimpleLibraryItem(from: libraryItem)) + } + + dataManager.saveContext() + + return processedFiles + } } // MARK: - Fetch library items @@ -1301,6 +1409,24 @@ extension LibraryService { return results.map { $0.relativePath } } + /// Every item's path with its uuid, in `fetchIdentifiers` order (a sync report pairs the + /// local tree with the server's records by uuid). View context: call on main. + public func fetchIdentifiersWithUuids() -> [(relativePath: String, uuid: String)] { + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() + fetchRequest.propertiesToFetch = [#keyPath(LibraryItem.relativePath), #keyPath(LibraryItem.uuid)] + fetchRequest.sortDescriptors = [ + NSSortDescriptor( + key: #keyPath(LibraryItem.relativePath), + ascending: true, + selector: #selector(NSString.localizedStandardCompare(_:)) + ) + ] + + let results = (try? self.dataManager.getContext().fetch(fetchRequest)) ?? [] + + return results.map { ($0.relativePath, $0.uuid) } + } + public func fetchContents(at relativePath: String?, limit: Int?, offset: Int?) -> [SimpleLibraryItem]? { let context = dataManager.getContext() @@ -1424,6 +1550,22 @@ extension LibraryService { return parseFetchedItems(from: results, context: context)?.first } + + public func getSimpleItem(for uuid: String) -> SimpleLibraryItem? { + let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "LibraryItem") + fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LibraryItem.uuid), uuid) + fetchRequest.fetchLimit = 1 + fetchRequest.propertiesToFetch = SimpleLibraryItem.fetchRequestProperties + fetchRequest.resultType = .dictionaryResultType + + let context = dataManager.getContext() + // performAndWait like the sibling find* methods: safe on the main-thread callers + // of today, correct if a background caller ever appears + return context.performAndWait { + let results = try? context.fetch(fetchRequest) as? [[String: Any]] + return parseFetchedItems(from: results, context: context)?.first + } + } func getItem(with relativePath: String, context: NSManagedObjectContext) -> LibraryItem? { let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() @@ -1733,6 +1875,62 @@ extension LibraryService { self.dataManager.saveSyncContext(context) return newBook } + + /// @MainActor: creates/mutates managed objects on the main-queue viewContext — running + /// this off the main thread is the CoreData threading violation the repo bans. + @MainActor + /// Internal (not public, not on the protocol): returns a managed object, which must + /// never cross the service boundary — the sole caller insertItems(fromResources:) + /// snapshots it to SimpleLibraryItem on the same context. + func createExternalBook(simpleItem: SimpleLibraryItem, externalResource: SimpleExternalResource) async -> LibraryItem { + let context = dataManager.getContext() + + let entity = NSEntityDescription.entity(forEntityName: "Book", in: context)! + let book = Book(entity: entity, insertInto: context) + // relativePath is the primary key of the library — two external books sharing a title + // (originalFileName is title-derived) must not collide, so scope by provider item id. + book.relativePath = "\(externalResource.providerId)-\(simpleItem.originalFileName)" + book.remoteURL = nil + book.artworkURL = simpleItem.artworkURL + let title = simpleItem.title + // The fallback must derive from the FILENAME — re-reading the same empty title + // made the underscore-replacement branch a no-op and showed a blank row + book.title = title.isEmpty + ? (simpleItem.originalFileName as NSString).deletingPathExtension.replacingOccurrences(of: "_", with: " ") + : title + let artist = simpleItem.details + book.details = artist.isEmpty ? "voiceover_unknown_author".localized : artist + book.duration = simpleItem.duration + book.currentTime = simpleItem.currentTime + book.percentCompleted = simpleItem.percentCompleted + book.originalFileName = simpleItem.originalFileName + book.isFinished = simpleItem.isFinished + book.type = .book + book.uuid = UUID().uuidString + + self.dataManager.saveSyncContext(context) + + let resourceEntity = NSEntityDescription.entity(forEntityName: "ExternalResource", in: context)! + let external = ExternalResource(entity: resourceEntity, insertInto: context) + + external.providerId = externalResource.providerId + external.providerName = externalResource.providerName + external.syncStatus = externalResource.syncStatus + external.lastSyncedAt = externalResource.lastSyncedAt + external.processedFile = externalResource.processedFile + external.hostId = externalResource.hostId + + external.libraryItem = book + book.addToExternalResources(external) + + // The server's chapters, if the hydration carried any. Written here rather than left + // to the progress pull so the FIRST play already has chapter navigation — nothing + // opens the file later to recover them. + storeChaptersIfEmpty(externalResource.chapters, for: book, context: context) + + self.dataManager.saveSyncContext(context) + return book + } public func loadChaptersIfNeeded(relativePath: String, asset: AVAsset) async { let context = dataManager.getBackgroundContext() @@ -1753,13 +1951,21 @@ extension LibraryService { return } - // Store chapters in the context, re-checking if still needed to avoid race conditions + await storeChaptersIfNeeded(relativePath: relativePath, chapters: chapters) + } + + public func storeChaptersIfNeeded(relativePath: String, chapters: [ChapterMetadata]) async { + guard !chapters.isEmpty else { return } + + let context = dataManager.getBackgroundContext() + + // The guard lives inside `perform` so it re-reads the book under the context that will + // write it — a caller's earlier check may have raced another writer. await context.perform { [unowned self] in guard let book = self.getItem(with: relativePath, context: context) as? Book, - book.chapters?.count == 0 else { - return - } - self.storeChapters(chapters, for: book, context: context) + self.storeChaptersIfEmpty(chapters, for: book, context: context) + else { return } + self.dataManager.saveSyncContext(context) } } @@ -2380,14 +2586,22 @@ extension LibraryService { } else { dataManager.saveContext() } - - progressPassthroughPublisher.send([ + + var params = [ #keyPath(LibraryItem.relativePath): relativePath, #keyPath(LibraryItem.currentTime): time, #keyPath(LibraryItem.lastPlayDate): date.timeIntervalSince1970, #keyPath(LibraryItem.percentCompleted): percentCompleted, #keyPath(LibraryItem.uuid): item.uuid - ]) + ] as [String : Any] + + if let externalResource = item.resourcesArray.first { + params[#keyPath(ExternalResource.providerId)] = externalResource.providerId + params[#keyPath(ExternalResource.providerName)] = externalResource.providerName + params["hostId"] = externalResource.hostId + } + + progressPassthroughPublisher.send(params) } func recursiveFolderLastPlayedDateUpdate(from relativePath: String, date: Date) { @@ -2738,6 +2952,179 @@ extension LibraryService { } } +extension LibraryService { + /// Managed-object variant for same-module mutation/diffing paths ONLY — never exposed + /// on the protocol. + func findResourceEntity(for providerId: String, providerName: String? = nil, context: NSManagedObjectContext? = nil) -> ExternalResource? { + let fetch: NSFetchRequest = ExternalResource.fetchRequest() + // Scope by provider when known: provider item ids are only unique per provider, and a + // Jellyfin id colliding with an ABS id must not resolve/mutate the other's resource. + if let providerName { + fetch.predicate = NSPredicate(format: "providerId == %@ AND providerName == %@", providerId, providerName) + } else { + fetch.predicate = NSPredicate(format: "providerId == %@", providerId) + } + let context = context ?? self.dataManager.getContext() + + let result = try? context.fetch(fetch) + + return result?.first + } + + func findResourceEntities(for uuid: String, context: NSManagedObjectContext? = nil) -> [ExternalResource]? { + let fetch: NSFetchRequest = ExternalResource.fetchRequest() + fetch.predicate = NSPredicate(format: "%K == %@", #keyPath(ExternalResource.libraryItem.uuid), uuid) + let context = context ?? self.dataManager.getContext() + + let result = try? context.fetch(fetch) + + return result + } + + public func findResource(for providerId: String, providerName: String?) -> SimpleExternalResource? { + let context = dataManager.getContext() + var snapshot: SimpleExternalResource? + context.performAndWait { + if let entity = findResourceEntity(for: providerId, providerName: providerName, context: context) { + snapshot = SimpleExternalResource(from: entity, ignoreLibraryItem: true) + } + } + return snapshot + } + + public func findResources(for uuid: String) -> [SimpleExternalResource]? { + let context = dataManager.getContext() + var snapshots: [SimpleExternalResource]? + context.performAndWait { + snapshots = findResourceEntities(for: uuid, context: context)? + .map { SimpleExternalResource(from: $0, ignoreLibraryItem: true) } + } + return snapshots + } + + public func findMediaServerResources(at relativePath: String?) async -> [SimpleExternalResource] { + // The background context is the one the cloud sync writes a level on, so the links it just + // reconciled are visible here directly, and the main thread does nothing for this query. + let context = dataManager.getBackgroundContext() + + return await context.perform { [unowned self] in + let fetch: NSFetchRequest = ExternalResource.fetchRequest() + let level: NSPredicate + if let relativePath { + level = NSPredicate( + format: "%K == %@", + #keyPath(ExternalResource.libraryItem.folder.relativePath), + relativePath + ) + } else { + level = NSPredicate(format: "%K != nil", #keyPath(ExternalResource.libraryItem.library)) + } + let mediaServers = NSPredicate( + format: "%K IN %@", + #keyPath(ExternalResource.providerName), + ExternalResource.ProviderName.mediaServerRawValues + ) + fetch.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [level, mediaServers]) + // The snapshot already reads `libraryItem` for the uuid and title, and the chapter + // lookup below needs its relativePath — one prefetch instead of a fault per row. + fetch.relationshipKeyPathsForPrefetching = [#keyPath(ExternalResource.libraryItem)] + + let resources = (try? context.fetch(fetch)) ?? [] + let chapterless = self.relativePathsWithoutChapters( + among: resources.compactMap { $0.libraryItem?.relativePath }, + context: context + ) + + return resources.map { resource in + var simple = SimpleExternalResource(from: resource, ignoreLibraryItem: true) + if let relativePath = resource.libraryItem?.relativePath { + simple.needsChapters = chapterless.contains(relativePath) + } + return simple + } + } + } + + /// Which of these books still have no chapters, answered SQL-side in one fetch. + /// + /// Asks `Book` directly rather than reaching through `ExternalResource.libraryItem`: + /// `chapters` is declared on `Book`, and that relationship's destination is the abstract + /// `LibraryItem`, so the keypath would not resolve. + private func relativePathsWithoutChapters( + among relativePaths: [String], + context: NSManagedObjectContext + ) -> Set { + guard !relativePaths.isEmpty else { return [] } + + let fetch = NSFetchRequest(entityName: "Book") + fetch.resultType = .dictionaryResultType + fetch.propertiesToFetch = [#keyPath(Book.relativePath)] + fetch.predicate = NSPredicate( + format: "%K IN %@ AND %K.@count == 0", + #keyPath(Book.relativePath), + relativePaths, + #keyPath(Book.chapters) + ) + + let results = (try? context.fetch(fetch)) ?? [] + return Set(results.compactMap { $0[#keyPath(Book.relativePath)] as? String }) + } + + /// Provider-neutral on purpose: this only ever used the position, the date and the finished + /// flag, and typing it to `JellyfinLibraryItem` is what kept AudiobookShelf items from being + /// refreshed at all — the caller could not even express an ABS batch. + @MainActor public func handleSyncFromExternalResource( + providerName: String, + snapshotsByProviderId: [String: ExternalItemSnapshot] + ) { + let remoteKeys = Array(snapshotsByProviderId.keys) + + let fetch: NSFetchRequest = ExternalResource.fetchRequest() + fetch.predicate = NSPredicate( + format: "%K == %@ AND %K IN %@", + #keyPath(ExternalResource.providerName), providerName, + #keyPath(ExternalResource.providerId), remoteKeys + ) + let context = self.dataManager.getContext() + + do { + let localResources = try context.fetch(fetch) + + for localResource in localResources { + // We already know this exists because of our predicate! + guard let localItem = localResource.libraryItem, + let snapshot = snapshotsByProviderId[localResource.providerId] else { + continue + } + + // Chapters ride the same response and land in the same save as the progress. + if let book = localItem as? Book { + self.storeChaptersIfEmpty(snapshot.chapters, for: book, context: context) + } + + let remoteItem = snapshot.progress + let localDate = localItem.lastPlayDate ?? .distantPast + let remoteDate = remoteItem.lastPlayedDate ?? .distantPast + + if remoteDate > localDate { + localItem.currentTime = remoteItem.currentTime + localItem.isFinished = remoteItem.isFinished ?? localItem.isFinished + localItem.lastPlayDate = remoteDate + // The library row's progress bar renders percentCompleted, not currentTime — + // without this the playhead moves but the row keeps the stale percentage + if localItem.duration > 0 { + localItem.percentCompleted = min(localItem.currentTime / localItem.duration, 1.0) * 100 + } + } + } + + dataManager.saveSyncContext(context) + } catch { + Self.logger.error("Failed to batch fetch ExternalResources: \(error)") + } + } +} + // MARK: - HardcoverBook operations extension LibraryService { public func setHardcoverBook(_ hardcoverBook: SimpleHardcoverBook?, for relativePath: String) async { @@ -2768,6 +3155,97 @@ extension LibraryService { } } + public func setExternalResource( + providerName: String, + providerId: String, + for uuid: String + ) async -> SyncableExternalResource? { + return await withCheckedContinuation { continuation in + let context = dataManager.getBackgroundContext() + + context.perform { [unowned self, context] in + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LibraryItem.uuid), uuid) + fetchRequest.fetchLimit = 1 + + guard let item = try? context.fetch(fetchRequest).first else { + continuation.resume(returning: nil) + return + } + + /// Skip if the same resource is already linked + if item.resourcesArray.contains(where: { + $0.providerName == providerName && $0.providerId == providerId + }) { + continuation.resume(returning: nil) + return + } + + let syncable = SyncableExternalResource( + providerName: providerName, + providerId: providerId, + syncStatus: ExternalResource.SyncStatus.notSynced.rawValue, + lastSyncedAt: nil, + processedFile: true, + hostId: nil + ) + + _ = ExternalResource.create(syncable, libraryItem: item, in: context) + + dataManager.saveSyncContext(context) + continuation.resume(returning: syncable) + } + } + } + + public func removeExternalResource( + providerName: String, + for uuid: String + ) async -> String? { + return await withCheckedContinuation { continuation in + let context = dataManager.getBackgroundContext() + + context.perform { [unowned self, context] in + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(LibraryItem.uuid), uuid) + fetchRequest.fetchLimit = 1 + + guard + let item = try? context.fetch(fetchRequest).first, + let resource = item.resourcesArray.first(where: { $0.providerName == providerName }) + else { + continuation.resume(returning: nil) + return + } + + let providerId = resource.providerId + item.removeFromExternalResources(resource) + context.delete(resource) + + dataManager.saveSyncContext(context) + continuation.resume(returning: providerId) + } + } + } + + public func getExternalResources(for relativePath: String) async -> [SimpleExternalResource] { + return await withCheckedContinuation { continuation in + let context = dataManager.getBackgroundContext() + + context.perform { [unowned self, context] in + guard let item = getItemReference(with: relativePath, context: context) else { + continuation.resume(returning: []) + return + } + + let resources = item.resourcesArray.map { + SimpleExternalResource(from: $0, ignoreLibraryItem: true) + } + continuation.resume(returning: resources) + } + } + } + public func getHardcoverBook(for relativePath: String) async -> SimpleHardcoverBook? { return await withCheckedContinuation { continuation in let context = dataManager.getBackgroundContext() @@ -2814,4 +3292,6 @@ extension LibraryService { } } } + + // swiftlint:enable force_cast diff --git a/Shared/Services/PlaybackService.swift b/Shared/Services/PlaybackService.swift index 6692d7da7..04b7c61e7 100644 --- a/Shared/Services/PlaybackService.swift +++ b/Shared/Services/PlaybackService.swift @@ -32,11 +32,18 @@ public protocol PlaybackServiceProtocol { @Observable public final class PlaybackService: PlaybackServiceProtocol { var libraryService: LibraryServiceProtocol! + /// Injected in `setup`, whose default keeps watchOS on its one-argument call — the watch + /// stores no media-server connections, so resolution there correctly finds nothing. + var streamResolver: ExternalStreamResolving! public init() {} - public func setup(libraryService: LibraryServiceProtocol) { + public func setup( + libraryService: LibraryServiceProtocol, + streamResolver: ExternalStreamResolving = ExternalStreamResolver() + ) { self.libraryService = libraryService + self.streamResolver = streamResolver } public func updatePlaybackTime(item: PlayableItem, time: Double) { @@ -221,6 +228,19 @@ public final class PlaybackService: PlaybackServiceProtocol { /// Ignore chapters that don't have the duration set properly chapters = chapters.filter { $0.duration > 0 } + // Resolution — which server, which URL, which auth headers — belongs to the resolver; + // this only records the outcome onto the chapters. + let externalResource = book.externalResources?.streamingResource + let streamSource = externalResource.flatMap { streamResolver.streamSource(for: $0) } + let externalUrl = streamSource?.url + let externalHeaders = streamSource?.headers ?? [:] + + // The resource exists but no stream source could be built for it — in practice because + // no saved connection matched its host; the resolver logs the one other way. Either way + // the file can't be streamed or re-downloaded on this device until its server is added. + let hasUnresolvedExternalHost = externalResource != nil && externalUrl == nil + + // If no chapters, create a single one using the book metadata guard !chapters.isEmpty else { return [ PlayableChapter( @@ -230,11 +250,15 @@ public final class PlaybackService: PlaybackServiceProtocol { duration: book.duration, relativePath: book.relativePath, remoteURL: book.remoteURL, - index: 1 + externalURL: externalUrl, + index: 1, + externalHeaders: externalHeaders, + hasUnresolvedExternalHost: hasUnresolvedExternalHost ) ] } + // Map existing chapters and apply resolved connection info return chapters.enumerated() .map({ (index, chapter) in return PlayableChapter( @@ -244,7 +268,10 @@ public final class PlaybackService: PlaybackServiceProtocol { duration: chapter.duration, relativePath: book.relativePath, remoteURL: book.remoteURL, - index: Int16(index + 1) + externalURL: externalUrl, + index: Int16(index + 1), + externalHeaders: externalHeaders, + hasUnresolvedExternalHost: hasUnresolvedExternalHost ) }) } @@ -325,8 +352,13 @@ public final class PlaybackService: PlaybackServiceProtocol { duration: truncatedDuration, relativePath: nestedChapter.relativePath, remoteURL: nestedChapter.remoteURL, + externalURL: nestedChapter.externalUrl, index: index, - chapterOffset: nestedChapters.count == 1 ? 0 : localCurrentDuration + chapterOffset: nestedChapters.count == 1 ? 0 : localCurrentDuration, + // Without the headers a streamed chapter inside a bound book hits the media + // server unauthenticated and 401s. + externalHeaders: nestedChapter.externalHeaders, + hasUnresolvedExternalHost: nestedChapter.hasUnresolvedExternalHost ) currentDuration = TimeParser.truncateTime(currentDuration + truncatedDuration) localCurrentDuration = TimeParser.truncateTime(localCurrentDuration + localDuration) diff --git a/Shared/Services/Sync/ApiResponse.swift b/Shared/Services/Sync/ApiResponse.swift index 4972f1309..3abb33eb1 100644 --- a/Shared/Services/Sync/ApiResponse.swift +++ b/Shared/Services/Sync/ApiResponse.swift @@ -8,6 +8,7 @@ public enum ApiResponse { case matchUuid(MatchUuidsResponse) + case uploadMetadata(UploadResponse) } public struct MatchUuidsResponse: Decodable { @@ -33,3 +34,11 @@ public struct ItemConflict: Decodable { self.uuid = uuid } } + +public struct UploadResponse: Decodable { + let uuid: String + let filePath: String + /// The library key of the item being uploaded: it names the upload's Queued Tasks row, + /// and can't be derived from the (absolute, possibly temp) filePath + var relativePath: String? +} diff --git a/Shared/Services/Sync/LibraryAPI.swift b/Shared/Services/Sync/LibraryAPI.swift index ae3e10f28..31a3218c8 100644 --- a/Shared/Services/Sync/LibraryAPI.swift +++ b/Shared/Services/Sync/LibraryAPI.swift @@ -9,9 +9,12 @@ import Foundation public enum LibraryAPI { - case syncedIdentifiers + /// The missing-items pass (bookplayer-api docs/multipart-uploads.md): of every uuid in the + /// local library, which the server has never seen and which are books without a file in S3 + case itemsStatus(uuids: [String]) case contents(path: String) case upload(params: [String: Any]) + case externalResource(params: [String: Any]) case update(params: [String: Any]) case move(origin: String, destination: String, uuid: String) case renameFolder(path: String, name: String, uuid: String) @@ -23,13 +26,26 @@ public enum LibraryAPI { case setBookmark(path: String, note: String?, time: Double, isActive: Bool, uuid: String) case uploadArtwork(path: String, filename: String, uploaded: Bool?, uuid: String) case matchUuids(uuidsDictionary: [String: String]) + case deleteExternalResource(uuid: String, providerName: String, providerId: String) + /// Multipart upload of a book's file (bookplayer-api docs/multipart-uploads.md). Every + /// call names the book by uuid; the server derives the S3 key. Call `startUpload` only + /// without an `uploadId` — it aborts any upload still open for the book. + case startUpload(uuid: String, fileSize: Int64, partSize: Int) + /// Presigned URLs for up to 32 parts + case uploadPartURLs(uuid: String, uploadId: String, partNumbers: [Int]) + /// The parts S3 already holds — the source of truth for resuming + case uploadedParts(uuid: String, uploadId: String) + /// Assembles the file; the server sets `synced` (and marks media-server resources + /// downloaded). Safe to repeat. + case completeUpload(uuid: String, uploadId: String, partCount: Int, fileSize: Int64) + case abortUpload(uuid: String, uploadId: String) } extension LibraryAPI: Endpoint { public var path: String { switch self { - case .syncedIdentifiers: - return "/v1/library/keys" + case .itemsStatus: + return "/v1/library/status" case .contents: return "/v1/library" case .upload: @@ -56,13 +72,25 @@ extension LibraryAPI: Endpoint { return "/v1/library/thumbnail_set" case .matchUuids: return "/v1/library/uuids" + case .externalResource: + return "/v1/library/external" + case .deleteExternalResource: + return "/v1/library/external" + case .startUpload: + return "/v1/library/upload/start" + case .uploadPartURLs, .uploadedParts: + return "/v1/library/upload/parts" + case .completeUpload: + return "/v1/library/upload/complete" + case .abortUpload: + return "/v1/library/upload/abort" } } public var method: HTTPMethod { switch self { - case .syncedIdentifiers: - return .get + case .itemsStatus: + return .post case .contents: return .get case .upload: @@ -89,19 +117,27 @@ extension LibraryAPI: Endpoint { return .post case .matchUuids: return .post + case .externalResource: + return .put + case .deleteExternalResource: + return .delete + case .startUpload, .uploadPartURLs, .completeUpload, .abortUpload: + return .post + case .uploadedParts: + return .get } } public var parameters: [String: Any]? { switch self { - case .syncedIdentifiers: - return nil + case .itemsStatus(let uuids): + return ["uuids": uuids] case .contents(let path): return [ "relativePath": path, "sign": true ] - case .upload( let params): + case .upload(let params): return params case .update(let params): return params @@ -124,6 +160,12 @@ extension LibraryAPI: Endpoint { "uuid": uuid as Any ] case .remoteContentsURL(let path, let uuid): + // `uuid` names the container; the trailing slash asks the API for its + // children, listed by the container's server-side key — so the listing is + // right even when this device still holds a pre-rename path. Requires the + // API contract shipped in bookplayer-api#49. (Until the GET encoder fix in + // NetworkClient, this uuid reached the server as `Optional("…")` and was + // silently ignored, so the path did all the work.) return [ "relativePath": "\(path)/", "sign": true, @@ -164,6 +206,24 @@ extension LibraryAPI: Endpoint { return [ "items": uuidsDictionary ] + case .externalResource(let params): + return params + case .deleteExternalResource(let uuid, let providerName, let providerId): + return [ + "uuid": uuid, + "providerName": providerName, + "providerId": providerId + ] + case .startUpload(let uuid, let fileSize, let partSize): + return ["uuid": uuid, "fileSize": fileSize, "partSize": partSize] + case .uploadPartURLs(let uuid, let uploadId, let partNumbers): + return ["uuid": uuid, "uploadId": uploadId, "partNumbers": partNumbers] + case .uploadedParts(let uuid, let uploadId): + return ["uuid": uuid, "uploadId": uploadId] + case .completeUpload(let uuid, let uploadId, let partCount, let fileSize): + return ["uuid": uuid, "uploadId": uploadId, "partCount": partCount, "fileSize": fileSize] + case .abortUpload(let uuid, let uploadId): + return ["uuid": uuid, "uploadId": uploadId] } } } diff --git a/Shared/Services/Sync/LibraryItemSyncOperation.swift b/Shared/Services/Sync/LibraryItemSyncOperation.swift index dd2ca9262..7bdc19630 100644 --- a/Shared/Services/Sync/LibraryItemSyncOperation.swift +++ b/Shared/Services/Sync/LibraryItemSyncOperation.swift @@ -7,69 +7,56 @@ // import Foundation -import Combine -/// Reference: https://www.avanderlee.com/swift/asynchronous-operations/ -class LibraryItemSyncOperation: Operation, BPLogger { - // MARK: - Async operation properties - - private var cellularDataObserver: NSKeyValueObservation? - private let lockQueue = DispatchQueue(label: "com.bookplayer.asyncoperation.synctask", attributes: .concurrent) - override var isAsynchronous: Bool { true } +class LibraryItemSyncOperation: AsyncOperation, BPLogger, @unchecked Sendable { + // MARK: - Library sync properties - private var _isExecuting: Bool = false - override private(set) var isExecuting: Bool { + let client: NetworkClientProtocol + let provider: NetworkProvider + let relativePath: String + let uuid: String + let jobType: SyncJobType + let parameters: [String: Any] + /// Whether the account's tier stores files in S3 (PRO). A book's `synced` means "its file + /// is in S3", so on any other tier the app never confirms one + let canUploadFiles: Bool + + /// Written from the operation's detached Task (and `cancel()`), read from the + /// queue-thread completionBlock — lock-guarded like the base class's `didSucceed` + /// so the reads have a happens-before edge with the writes. + private let propertyLock = NSLock() + private var _results: ApiResponse? + var results: ApiResponse? { get { - return lockQueue.sync { () -> Bool in - return _isExecuting - } + propertyLock.lock(); defer { propertyLock.unlock() } + return _results } set { - willChangeValue(forKey: "isExecuting") - lockQueue.sync(flags: [.barrier]) { - _isExecuting = newValue - } - didChangeValue(forKey: "isExecuting") + propertyLock.lock(); defer { propertyLock.unlock() } + _results = newValue } } - - private var _isFinished: Bool = false - override private(set) var isFinished: Bool { + private var _error: Error? + var error: Error? { get { - return lockQueue.sync { () -> Bool in - return _isFinished - } + propertyLock.lock(); defer { propertyLock.unlock() } + return _error } set { - willChangeValue(forKey: "isFinished") - lockQueue.sync(flags: [.barrier]) { - _isFinished = newValue - } - didChangeValue(forKey: "isFinished") + propertyLock.lock(); defer { propertyLock.unlock() } + _error = newValue } } - - // MARK: - Library sync properties - - let client: NetworkClientProtocol - let provider: NetworkProvider - let relativePath: String - let uuid: String - let jobType: SyncJobType - let parameters: [String: Any] - var results: ApiResponse? - var error: Error? - private var progressSubscriber: AnyCancellable? - private var completionSubscriber: AnyCancellable? - /// Initializer /// - Parameters: /// - client: Network client /// - task: Sync task to be handled in the operation + /// - canUploadFiles: Whether the account's tier stores files in S3 init( client: NetworkClientProtocol, - task: SyncTask + task: SyncTask, + canUploadFiles: Bool ) { self.client = client self.provider = NetworkProvider(client: client) @@ -77,23 +64,40 @@ class LibraryItemSyncOperation: Operation, BPLogger { self.jobType = task.jobType self.parameters = task.parameters self.uuid = task.uuid + self.canUploadFiles = canUploadFiles } - override func start() { - guard !isCancelled else { - finish() - return + /// Written in main() on the queue thread, read/cancelled by cancel() from any thread + /// (logout/lapse) — same lock discipline as error/results, or cancel() can read a + /// stale nil and skip cancelling the in-flight Task. + private var _executionTask: Task? + private var executionTask: Task? { + get { + propertyLock.lock(); defer { propertyLock.unlock() } + return _executionTask + } + set { + propertyLock.lock(); defer { propertyLock.unlock() } + _executionTask = newValue } - - isFinished = false - isExecuting = true - main() } // TODO: split into separate Operations override func main() { - Task { + executionTask = Task { do { + // Two flags cover every cancel interleaving — an in-flight op must not finish + // a PUT or post confirmations under the NEXT signed-in account's token + // (NetworkClient reads the keychain token per request): + // 1. cancel() ran BEFORE this Task existed (the start()→assignment window): + // executionTask was nil there, so only the OPERATION flag catches it. + guard !isCancelled else { + finish() + return + } + // 2. cancel() ran after: the propertyLock guarantees it saw the Task and set + // the TASK flag — checked here and at every URLSession suspension point. + try Task.checkCancellation() switch jobType { case .upload: guard @@ -141,6 +145,26 @@ class LibraryItemSyncOperation: Operation, BPLogger { case .matchUuid: try await handleMatchUuids() finish() + case .externalResource: + let _: Empty = try await self.provider.request(.externalResource(params: self.parameters)) + finish() + case .externalResourceToDownload: + try await handleExternalResourceToDownload() + finish() + case .deleteExternalResource: + guard + let providerName = parameters["providerName"] as? String, + let providerId = parameters["providerId"] as? String + else { + throw BookPlayerError.runtimeError("Missing parameters for deleting an external resource") + } + let _: Empty = try await self.provider.request( + .deleteExternalResource(uuid: uuid, providerName: providerName, providerId: providerId) + ) + finish() + case .externalUpdate, .uploadFile: + /// Handled by their dedicated operations, never routed here + throw BookPlayerError.runtimeError("Unsupported job type for sync operation: \(jobType.rawValue)") } } catch { self.error = error @@ -149,9 +173,21 @@ class LibraryItemSyncOperation: Operation, BPLogger { } } - func finish() { - isExecuting = false - isFinished = true + override func finish() { + didSucceed = error == nil + super.finish() + } + + override func cancel() { + super.cancel() + // Mark failed BEFORE finishing so a logout-cancelled op can never be treated + // as succeeded, then release the queue slot; finish() is idempotent, so the + // cancelled Task's own finish() later is a no-op. + if error == nil { + error = BookPlayerError.cancelledTask + } + executionTask?.cancel() + if isExecuting { finish() } } } @@ -159,11 +195,20 @@ class LibraryItemSyncOperation: Operation, BPLogger { extension LibraryItemSyncOperation { func handleUploadJob(type: SimpleItemType) async throws { - let response: UploadItemResponse = try await provider.request(.upload(params: parameters)) - + /// `provider` is client-side metadata (gates the follow-up file upload); keep it out of the request + let uploadParams = parameters.filter { $0.key != "provider" } + let response: UploadItemResponse = try await provider.request(.upload(params: uploadParams)) guard let remoteURL = response.content.url else { - /// The file is already present in the storage - try await markUploadAsSynced(uuid: self.uuid) + /// The file is already present in the storage (or the tier doesn't store files): the + /// book's hard link scheduled for the upload will never be read + if type == .book { + SyncJobScheduler.removeHardLink(at: SyncJobScheduler.hardLinkURL(for: self.relativePath)) + } + /// Without S3 access no URL only means "this tier stores no file", so a book stays + /// unconfirmed: it's what lets its file go up if the account becomes PRO + if type != .book || canUploadFiles { + try await markUploadAsSynced(uuid: self.uuid) + } finish() return } @@ -180,7 +225,7 @@ extension LibraryItemSyncOperation { return } - let hardLinkURL = FileManager.default.temporaryDirectory.appendingPathComponent(self.relativePath) + let hardLinkURL = SyncJobScheduler.hardLinkURL(for: self.relativePath) /// Prefer the hard link URL and fallback to recorded item path /// Note: the recorded item path may not have the item if the user moved it @@ -196,114 +241,10 @@ extension LibraryItemSyncOperation { return } - await uploadFile( - fileURL: fileURL, - remoteURL: remoteURL, - relativePath: self.relativePath - ) - } - - /// Upload file on a background thread - func uploadFile( - fileURL: URL, - remoteURL: URL, - relativePath: String - ) async { - let session: URLSession = UserDefaults.standard.bool(forKey: Constants.UserDefaults.allowCellularData) - ? BPURLSession.shared.backgroundCellularSession - : BPURLSession.shared.backgroundSession - - let uploadTask = await self.client.uploadTask( - fileURL, - remoteURL: remoteURL, - taskDescription: relativePath, - session: session - ) - - bindUploadObservers() - - cellularDataObserver?.invalidate() - cellularDataObserver = UserDefaults.standard.observe( - \.userSettingsAllowCellularData, - options: [.new] - ) { [weak self] _, change in - guard let newValue = change.newValue else { return } - - let previousSession: URLSession = newValue - ? BPURLSession.shared.backgroundSession - : BPURLSession.shared.backgroundCellularSession - - self?.rescheduleUploadFile( - fileURL: fileURL, - remoteURL: remoteURL, - relativePath: relativePath, - previousSession: previousSession - ) - } - - uploadTask.resume() - } - - func bindUploadObservers() { - progressSubscriber?.cancel() - progressSubscriber = BPURLSession.shared.progressPublisher.sink(receiveValue: { [uuid, relativePath] (path, progress) in - guard path == relativePath else { return } - NotificationCenter.default.post( - name: .uploadProgressUpdated, - object: nil, - userInfo: [ - "progress": progress, - "relativePath": path, - "uuid": uuid - ] - ) - }) - - completionSubscriber?.cancel() - completionSubscriber = BPURLSession.shared.completionPublisher.sink(receiveValue: { [weak self] (task, error) in - self?.cellularDataObserver?.invalidate() - if let nserror = error as? NSError, - nserror.domain == NSURLErrorDomain, - nserror.code == NSURLErrorCancelled { - /// Do nothing, as the task is already being rescheduled - } else if let error { - self?.error = error - self?.finish() - } else { - self?.handleUploadFinished(task) - } - }) - } - - func rescheduleUploadFile( - fileURL: URL, - remoteURL: URL, - relativePath: String, - previousSession: URLSession - ) { - Task { - let task = await previousSession.allTasks.filter({ $0.taskDescription == relativePath }).first - task?.cancel() - - await self.uploadFile( - fileURL: fileURL, - remoteURL: remoteURL, - relativePath: relativePath - ) - } - } - - func handleUploadFinished(_ task: URLSessionTask) { - Task { [task] in - do { - try await markUploadAsSynced(uuid: uuid) - NotificationCenter.default.post(name: .uploadCompleted, object: task) - finish() - } catch { - self.error = error - finish() - } - } + // The URL only says the server needs the bytes: the upload lane sends them as a + // multipart upload, and the server marks the row synced when it assembles the file + results = .uploadMetadata(UploadResponse(uuid: self.uuid, filePath: fileURL.absoluteString, relativePath: self.relativePath)) + finish() } func markUploadAsSynced(uuid: String) async throws { @@ -377,6 +318,25 @@ extension LibraryItemSyncOperation { } } +extension LibraryItemSyncOperation { + /// A media-server book finished downloading on a PRO account: its file goes to S3 like + /// any book's, through the upload lane. Run from the sync lane so it follows the task + /// that created the item's row; `complete` marks its media-server resources downloaded. + func handleExternalResourceToDownload() async throws { + let hardLinkURL = SyncJobScheduler.hardLinkURL(for: self.relativePath) + let fileURL = FileManager.default.fileExists(atPath: hardLinkURL.path) + ? hardLinkURL + : DataManager.getProcessedFolderURL().appendingPathComponent(self.relativePath) + + // No file (or no uuid to name the book by): nothing to upload, and retrying can't heal it + guard !uuid.isEmpty, FileManager.default.fileExists(atPath: fileURL.path) else { return } + + results = .uploadMetadata( + UploadResponse(uuid: uuid, filePath: fileURL.absoluteString, relativePath: relativePath) + ) + } +} + extension LibraryItemSyncOperation { func handleMatchUuids() async throws { guard diff --git a/Shared/Services/Sync/Model/IdentifiersResponse.swift b/Shared/Services/Sync/Model/IdentifiersResponse.swift deleted file mode 100644 index 91272c5a7..000000000 --- a/Shared/Services/Sync/Model/IdentifiersResponse.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// IdentifiersResponse.swift -// BookPlayer -// -// Created by gianni.carlo on 6/7/23. -// Copyright © 2023 BookPlayer LLC. All rights reserved. -// - -import Foundation - -struct IdentifiersResponse: Decodable { - let content: [String] -} diff --git a/Shared/Services/Sync/Model/ItemsStatusResponse.swift b/Shared/Services/Sync/Model/ItemsStatusResponse.swift new file mode 100644 index 000000000..20a65808e --- /dev/null +++ b/Shared/Services/Sync/Model/ItemsStatusResponse.swift @@ -0,0 +1,17 @@ +// +// ItemsStatusResponse.swift +// BookPlayer +// +// Created by gianni.carlo on 6/7/23. +// Copyright © 2023 BookPlayer LLC. All rights reserved. +// + +import Foundation + +/// `POST /v1/library/status`: each uuid comes back spelled as it was sent +struct ItemsStatusResponse: Decodable { + /// No row has the uuid, active or deleted: the item needs registering + let unknown: [String] + /// An active book whose file isn't in S3 + let unsynced: [String] +} diff --git a/Shared/Services/Sync/Model/MultipartUploadResponse.swift b/Shared/Services/Sync/Model/MultipartUploadResponse.swift new file mode 100644 index 000000000..855fd264a --- /dev/null +++ b/Shared/Services/Sync/Model/MultipartUploadResponse.swift @@ -0,0 +1,55 @@ +// +// MultipartUploadResponse.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +/// `POST /v1/library/upload/start` +struct StartUploadResponse: Decodable { + enum Status: String, Decodable { + /// A new upload is open: send its parts + case started + /// S3 already has the file; the server marked the row synced. Nothing to send. + case exists + } + + let status: Status + let uploadId: String? + let partSize: Int? + let partCount: Int? +} + +/// `POST /v1/library/upload/parts` +struct UploadPartURLsResponse: Decodable { + struct Part: Decodable { + let partNumber: Int + let url: URL + /// Unix seconds. An upper bound only: the signing credentials can end the URL sooner. + let expiresAt: TimeInterval + } + + let parts: [Part] +} + +/// `GET /v1/library/upload/parts` +struct UploadedPartsResponse: Decodable { + struct Part: Decodable { + let partNumber: Int + let size: Int64 + } + + let parts: [Part] +} + +/// `POST /v1/library/upload/complete` +struct CompleteUploadResponse: Decodable { + let synced: Bool +} + +/// `POST /v1/library/upload/abort` +struct AbortUploadResponse: Decodable { + let aborted: Bool +} diff --git a/Shared/Services/Sync/Model/RemoteFileURLResponse.swift b/Shared/Services/Sync/Model/RemoteFileURLResponse.swift index 19e6ff2d9..48ab6682f 100644 --- a/Shared/Services/Sync/Model/RemoteFileURLResponse.swift +++ b/Shared/Services/Sync/Model/RemoteFileURLResponse.swift @@ -12,9 +12,10 @@ public struct RemoteFileURL: Decodable { public let url: URL public let relativePath: String public let type: SimpleItemType + public let headers: [String: String]? enum CodingKeys: CodingKey { - case url, relativePath, type + case url, relativePath, type, headers } } diff --git a/Shared/Services/Sync/Model/SyncTask.swift b/Shared/Services/Sync/Model/SyncTask.swift index f2775836b..eba1c756e 100644 --- a/Shared/Services/Sync/Model/SyncTask.swift +++ b/Shared/Services/Sync/Model/SyncTask.swift @@ -37,26 +37,6 @@ public struct SyncTask: Identifiable { } } -public struct SyncTaskReference: Identifiable { - public let id: String - public let uuid: String - public let relativePath: String - public let jobType: SyncJobType - public let progress: Double - - public init(id: String, uuid: String, relativePath: String, jobType: SyncJobType, progress: Double) { - self.id = id - self.uuid = uuid - self.relativePath = relativePath - self.jobType = jobType - self.progress = progress - } - - public var progressKey: String { - SyncProgressKey.resolve(uuid: uuid, relativePath: relativePath) - } -} - /// Information about the last sync error for debugging purposes public struct SyncErrorInfo { public let taskId: String diff --git a/Shared/Services/Sync/Model/SyncableExternalResource.swift b/Shared/Services/Sync/Model/SyncableExternalResource.swift new file mode 100644 index 000000000..6313aa12b --- /dev/null +++ b/Shared/Services/Sync/Model/SyncableExternalResource.swift @@ -0,0 +1,67 @@ +// +// SyncableExternalResource.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 27/3/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +public struct SyncableExternalResource { + public let providerName: String + public let providerId: String + public let syncStatus: String + public let lastSyncedAt: Date? + public let processedFile: Bool + public let hostId: String? + + static var fetchRequestProperties = [ + "providerName", + "providerId", + "syncStatus", + "lastSyncedAt", + "processedFile", + "hostId" + ] +} + +extension SyncableExternalResource: Decodable { + enum CodingKeys: String, CodingKey { + case providerName + case providerId + case syncStatus + case lastSyncedAt + case processedFile + case hostId + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.providerName = try container.decode(String.self, forKey: .providerName) + self.providerId = try container.decode(String.self, forKey: .providerId) + self.syncStatus = try container.decode(String.self, forKey: .syncStatus) + // decodeIfPresent, not try?: an ABSENT key is fine, but a TYPE MISMATCH should + // throw — LossyDecodableArray then drops (and logs) the malformed element instead + // of silently nil-ing the field. + self.lastSyncedAt = try container.decodeIfPresent(Date.self, forKey: .lastSyncedAt) + // Absent processedFile must not drop the whole resource element (losing the book's + // media-server link on this device); false is the conservative default — reprocess + // rather than skip processing. + self.processedFile = try container.decodeIfPresent(Bool.self, forKey: .processedFile) ?? false + self.hostId = try container.decodeIfPresent(String.self, forKey: .hostId) + } +} + +extension SyncableExternalResource { + public init(from item: SimpleExternalResource) { + self.providerName = item.providerName + self.providerId = item.providerId + self.syncStatus = item.syncStatus + self.lastSyncedAt = item.lastSyncedAt + // Thread the real value: hardcoding true reported not-yet-processed resources as + // processed to the server (and to every other device that syncs them down) + self.processedFile = item.processedFile + self.hostId = item.hostId + } +} diff --git a/Shared/Services/Sync/Model/SyncableItem.swift b/Shared/Services/Sync/Model/SyncableItem.swift index 5d0915eb4..326bf59c4 100644 --- a/Shared/Services/Sync/Model/SyncableItem.swift +++ b/Shared/Services/Sync/Model/SyncableItem.swift @@ -24,6 +24,7 @@ public struct SyncableItem { public let lastPlayDateTimestamp: Double? let type: SimpleItemType let uuid: String + var externalResources: [SyncableExternalResource]? static var fetchRequestProperties = [ "relativePath", @@ -61,6 +62,7 @@ extension SyncableItem: Decodable { case lastPlayDateTimestamp case type case uuid + case externalResources } public init(from decoder: Decoder) throws { @@ -80,10 +82,40 @@ extension SyncableItem: Decodable { self.orderRank = try container.decodeIfPresent(Int.self, forKey: .orderRank) ?? 0 self.lastPlayDateTimestamp = try container.decodeIfPresent(Double.self, forKey: .lastPlayDateTimestamp) self.type = try container.decode(SimpleItemType.self, forKey: .type) + // Lossy per-element decode: SyncableExternalResource has required fields, so one + // malformed element in the server payload must drop THAT element, not silently + // discard every external resource the item has. + self.externalResources = ( + try? container.decode(LossyDecodableArray.self, forKey: .externalResources) + )?.elements self.uuid = myUuid ?? "" } } +/// Decodes each array element independently, skipping the ones that fail. +struct LossyDecodableArray: Decodable { + let elements: [Element] + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + var elements: [Element] = [] + while !container.isAtEnd { + if let element = try? container.decode(Element.self) { + elements.append(element) + } else { + // A failed decode does NOT advance the index — consume the malformed + // element with an always-succeeding decode or this loops forever + _ = try? container.decode(AnyDecodableValue.self) + } + } + self.elements = elements + } + + private struct AnyDecodableValue: Decodable { + init(from decoder: Decoder) throws {} + } +} + extension SyncableItem { public init(from item: SimpleLibraryItem) { self.relativePath = item.relativePath @@ -101,6 +133,9 @@ extension SyncableItem { self.lastPlayDateTimestamp = item.lastPlayDate?.timeIntervalSince1970 self.type = item.type self.uuid = item.uuid + self.externalResources = item.externalResources?.map({ + SyncableExternalResource(from: $0) + }) } public func copy( @@ -121,7 +156,20 @@ extension SyncableItem { orderRank: self.orderRank, lastPlayDateTimestamp: self.lastPlayDateTimestamp, type: self.type, - uuid: uuid ?? self.uuid + uuid: uuid ?? self.uuid, + // Every field must survive a copy: the synthesized default silently dropped the + // external resources for items whose uuid gets minted in processContentsResponse. + externalResources: self.externalResources ) } } + +extension SyncableItem { + /// The media server this book streams from, if any. A Hardcover link is not one: it has + /// no file, so it must never keep the book's own file from uploading. + public var mediaServerProviderName: String? { + externalResources? + .first { ExternalResource.ProviderName(rawValue: $0.providerName)?.mediaServer != nil }? + .providerName + } +} diff --git a/Shared/Services/Sync/Model/UploadItemResponse.swift b/Shared/Services/Sync/Model/UploadItemResponse.swift index 6d0b338cd..e61d4255f 100644 --- a/Shared/Services/Sync/Model/UploadItemResponse.swift +++ b/Shared/Services/Sync/Model/UploadItemResponse.swift @@ -12,6 +12,6 @@ struct UploadItemResponse: Decodable { let content: UploadItemContent } -struct UploadItemContent: Decodable { +public struct UploadItemContent: Decodable { let url: URL? } diff --git a/Shared/Services/Sync/SyncJobScheduler.swift b/Shared/Services/Sync/SyncJobScheduler.swift index 4e185b30e..2461be7a5 100644 --- a/Shared/Services/Sync/SyncJobScheduler.swift +++ b/Shared/Services/Sync/SyncJobScheduler.swift @@ -6,21 +6,15 @@ // Copyright © 2022 BookPlayer LLC. All rights reserved. // -import Combine import Foundation -import CoreData -import SwiftData public protocol JobSchedulerProtocol { - /// Last sync error information for debugging - var lastSyncError: SyncErrorInfo? { get } - func queuedJobsCount() async -> Int /// Uploads the metadata for the first time to the server func scheduleLibraryItemUploadJob(for item: SyncableItem) async /// Update existing metadata in the server func scheduleMetadataUpdateJob(with relativePath: String, parameters: [String: Any]) async - + func scheduleMatchUuidsJob(uuidsDict: [String: String]) async /// Move item to destination func scheduleMoveItemJob(with itemOrigin: LibraryItemRef, to parentFolder: LibraryItemRef?) async @@ -39,114 +33,74 @@ public protocol JobSchedulerProtocol { func scheduleRenameFolderJob(with relativePath: String, name: String, for uuid: String) async /// Upload current cached artwork func scheduleArtworkUpload(with relativePath: String, for uuid: String) async - /// Get all queued jobs - func getAllQueuedJobs() async -> [SyncTaskReference] /// Get all queued jobs with full parameters func getAllQueuedJobsWithParams() async -> [SyncTask] - /// Cancel all stored and ongoing jobs + /// Cancel all stored jobs across every queue func cancelAllJobs() - /// Cancel all stored and ongoing jobs and wait for completion + /// Cancel all stored sync jobs and wait for completion func resetAllJobs() async /// Check if there's an upload task queued for the item func hasUploadTask(for relativePath: String) async -> Bool + + func scheduleExternalResourceUpload(for externalResource: SyncableExternalResource, itemOrigin: LibraryItemRef) async + /// Delete an external resource on the server + func scheduleDeleteExternalResource(providerName: String, providerId: String, itemOrigin: LibraryItemRef) async + + /// Upload a downloaded media-server book's file (queued behind the item's own sync tasks) + func scheduleResourceToDownload(with relativePath: String, for uuid: String?) async } +/// Scheduling facade for the serial BookPlayer-server queue: builds task parameters +/// and persists them under the `sync` queue key. Execution lives in `SyncQueueService`. public class SyncJobScheduler: JobSchedulerProtocol, BPLogger { - let networkClient: NetworkClientProtocol - let operationQueue: OperationQueue - let tasksDataManager: TasksDataManager - let dataManager: DataManager - - /// Reference for observer - private var syncTasksObserver: NSKeyValueObservation? - private var disposeBag = Set() - private let lockQueue = DispatchQueue(label: "com.bookplayer.synctask.schedule") - /// Reference to ongoing library fetch task - private var initializeStoreTask: Task<(), Error>? - private var taskStore: SyncTasksStorage! - private var tasksProgress: [String: Double] = [:] - /// Last sync error information for debugging - public private(set) var lastSyncError: SyncErrorInfo? - - public init( - tasksDataManager: TasksDataManager, - networkClient: NetworkClientProtocol = NetworkClient(), - operationQueue: OperationQueue = OperationQueue(), - dataManager: DataManager - ) { - operationQueue.maxConcurrentOperationCount = 1 - self.operationQueue = operationQueue - self.networkClient = networkClient - self.tasksDataManager = tasksDataManager - self.dataManager = dataManager - - bindObservers() + let tasksRepository: SyncQueueRepositoryProtocol + + public init(tasksRepository: SyncQueueRepositoryProtocol) { + self.tasksRepository = tasksRepository } - - func bindObservers() { - NotificationCenter.default.publisher(for: .uploadCompleted) - .sink { notification in - guard - let task = notification.object as? URLSessionTask, - let relativePath = task.taskDescription - else { return } - - do { - let hardLinkURL = FileManager.default.temporaryDirectory.appendingPathComponent(relativePath) - try FileManager.default.removeItem(at: hardLinkURL) - } catch { - Self.logger.warning("Failed to delete hard link for \(relativePath): \(error.localizedDescription)") - } - } - .store(in: &disposeBag) - - NotificationCenter.default.publisher(for: .uploadProgressUpdated) - .receive(on: DispatchQueue.main) - .sink { [weak self] notification in - guard - let uuid = notification.userInfo?["uuid"] as? String, - let relativePath = notification.userInfo?["relativePath"] as? String, - let progress = notification.userInfo?["progress"] as? Double - else { return } - let key = SyncProgressKey.resolve(uuid: uuid, relativePath: relativePath) - self?.updateProgress(for: key, value: progress) - } - .store(in: &disposeBag) - - initializeStore() + + /// `POST /v1/library/uuids` rejects more items than this in one request (the server's + /// MAX_RECORDS_LIMIT), with an error the queue would retry forever. + public static let matchUuidsBatchLimit = 1000 + + /// Where `scheduleLibraryItemUploadJob` hard-links a book so the upload survives the + /// user moving it in the library. + static func hardLinkURL(for relativePath: String) -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent(relativePath) } - - private func initializeStore() { - initializeStoreTask = Task.detached { - do { - self.taskStore = try SyncTasksStorage(tasksDataManager: self.tasksDataManager) - } catch { - fatalError("Failed to initialize sync tasks store: \(error.localizedDescription)") - } - - /// This will start the loop where it will periodically check for queued tasks to execute - self.queueNextTask() - } + + /// Removes an upload's temp hard link once nothing will read it. Only ever deletes a regular + /// file inside the temp directory: never a real Processed-folder file, never the temp + /// directory itself, and never a directory (a folder item's `linkItem` mirrors its whole + /// tree, and its children's queued uploads still read those links). + static func removeHardLink(at url: URL?) { + let tempPath = FileManager.default.temporaryDirectory.path + guard + let url, + url.path.hasPrefix(tempPath.hasSuffix("/") ? tempPath : tempPath + "/"), + (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true + else { return } + try? FileManager.default.removeItem(at: url) } - + private func createHardLink(for item: SyncableItem) { - let hardLinkURL = FileManager.default.temporaryDirectory.appendingPathComponent(item.relativePath) - + let hardLinkURL = Self.hardLinkURL(for: item.relativePath) + let fileURL = DataManager.getProcessedFolderURL().appendingPathComponent(item.relativePath) - + /// Clean up in case hard link path is already used if FileManager.default.fileExists(atPath: hardLinkURL.path) { try? FileManager.default.removeItem(at: hardLinkURL) } - + /// Don't throw and let the rest of the items queue up try? FileManager.default.linkItem(at: fileURL, to: hardLinkURL) } - + public func scheduleLibraryItemUploadJob(for item: SyncableItem) async { /// Create hard link to file location in case the user moves the item around in the library createHardLink(for: item) - + var parameters: [String: Any] = [ "id": UUID().uuidString, "uuid": item.uuid, @@ -162,20 +116,60 @@ public class SyncJobScheduler: JobSchedulerProtocol, BPLogger { "type": item.type.rawValue, "jobType": SyncJobType.upload.rawValue, ] - + if let lastPlayTimestamp = item.lastPlayDateTimestamp { parameters["lastPlayDateTimestamp"] = Int(lastPlayTimestamp) } else { parameters["lastPlayDateTimestamp"] = nil } - + if let speed = item.speed { parameters["speed"] = speed } - + + // Media-server books upload their file once downloaded (the pipe job), not from this + // job's answer + if let provider = item.mediaServerProviderName { + parameters["provider"] = provider + } + await persistTask(parameters: parameters) } - + + public func scheduleExternalResourceUpload(for externalResource: SyncableExternalResource, itemOrigin: LibraryItemRef) async { + let parameters: [String: Any] = [ + "id": UUID().uuidString, + "providerId": externalResource.providerId, + "providerName": externalResource.providerName, + "lastSyncedAt": externalResource.lastSyncedAt as Any, + "processedFile": externalResource.processedFile, + "syncStatus": externalResource.syncStatus, + "hostId": externalResource.hostId as Any, + "jobType": SyncJobType.externalResource.rawValue, + "uuid": itemOrigin.uuid, + "relativePath": itemOrigin.relativePath + ] + + await persistTask(parameters: parameters) + } + + public func scheduleDeleteExternalResource( + providerName: String, + providerId: String, + itemOrigin: LibraryItemRef + ) async { + let parameters: [String: Any] = [ + "id": UUID().uuidString, + "providerName": providerName, + "providerId": providerId, + "jobType": SyncJobType.deleteExternalResource.rawValue, + "uuid": itemOrigin.uuid, + "relativePath": itemOrigin.relativePath + ] + + await persistTask(parameters: parameters) + } + public func scheduleMoveItemJob(with itemOrigin: LibraryItemRef, to parentFolder: LibraryItemRef?) async { let useUuids = Constants.isRealUuid(itemOrigin.uuid) @@ -190,49 +184,54 @@ public class SyncJobScheduler: JobSchedulerProtocol, BPLogger { await persistTask(parameters: parameters) } - + /// Note: folder renames originalFilename property public func scheduleMetadataUpdateJob(with relativePath: String, parameters: [String: Any]) async { var parameters = parameters parameters["jobType"] = SyncJobType.update.rawValue parameters["id"] = UUID().uuidString - + await persistTask(parameters: parameters) } - + public func scheduleMatchUuidsJob(uuidsDict: [String: String]) async { guard !uuidsDict.isEmpty else { return } - - let parameters: [String: Any] = [ - "jobType": SyncJobType.matchUuid.rawValue, - "id": UUID().uuidString, - "relativePath": "", - "uuid": "", - "uuids": uuidsDict - ] - await persistTask(parameters: parameters) + + /// One task per server-sized batch: a larger request can never succeed + let paths = uuidsDict.keys.sorted() + for start in stride(from: 0, to: paths.count, by: Self.matchUuidsBatchLimit) { + let batch = paths[start.. [SyncTaskReference] { - _ = await initializeStoreTask?.result - let currentProgress = await MainActor.run { tasksProgress } - return await taskStore.getAllTasks(progress: currentProgress) + + public func scheduleResourceToDownload(with relativePath: String, for uuid: String?) async { + var params: [String: Any] = [ + "id": UUID().uuidString, + "relativePath": relativePath, + "jobType": SyncJobType.externalResourceToDownload.rawValue, + ] + + if let uuid = uuid { + params["uuid"] = uuid + } + + await persistTask(parameters: params) } - + public func getAllQueuedJobsWithParams() async -> [SyncTask] { - _ = await initializeStoreTask?.result - return await taskStore.getAllTasksWithParams() + return await tasksRepository.getAllTasksWithParams(in: TaskQueueKey.sync) } - + public func cancelAllJobs() { Task { - _ = await initializeStoreTask?.result - try await taskStore.clearAll() - operationQueue.cancelAllOperations() - await MainActor.run { - tasksProgress.removeAll() - } + // Subscription lapse (pro/lite → free while signed in), NOT logout: the + // provider-keyed externalUpdate queues must survive — progress pushes to the + // user's OWN media server stay enabled on every tier (accessPolicy). Only the + // BookPlayer-server queue and the pro-gated upload queue are wiped; logout + // goes through resetAllJobs(), which clears everything. + try? await tasksRepository.clearAll(in: TaskQueueKey.sync) + try? await tasksRepository.clearAll(in: TaskQueueKey.uploadFile) } } - + public func resetAllJobs() async { - _ = await initializeStoreTask?.result - try? await taskStore.clearAll() - operationQueue.cancelAllOperations() - await MainActor.run { - tasksProgress.removeAll() - } + // Logout must wipe EVERY queue, not just the serial sync queue (develop parity): + // surviving uploadFile/externalUpdate tasks would run under the NEXT signed-in + // account, pushing the previous user's items with the new account's token. + try? await tasksRepository.clearAll() } - + public func queuedJobsCount() async -> Int { - _ = await initializeStoreTask?.result - return await taskStore.getTasksCount() + return await tasksRepository.getTasksCount(in: TaskQueueKey.sync) } - + private func persistTask(parameters: [String: Any]) async { + var parameters = parameters + parameters["queueKey"] = TaskQueueKey.sync do { - _ = await initializeStoreTask?.result - try await taskStore.appendTask(parameters: parameters) + try await tasksRepository.storeTask(parameters: parameters) } catch { Self.logger.error("Failed to persist task \(error): \(parameters.description)") } } - + /// Check if there's an upload task queued for the item public func hasUploadTask(for relativePath: String) async -> Bool { - return await taskStore.hasUploadTask(for: relativePath) - } - - private func queueNextTask() { - Task { - _ = await initializeStoreTask?.result - - do { - guard - let task = try await self.taskStore.getNextTask() - else { - self.retryQueuedTask() - return - } - - let operationTask = LibraryItemSyncOperation( - client: networkClient, - task: task - ) - - operationTask.completionBlock = { [weak self, weak operationTask] in - guard let self else { - return - } - let error = operationTask?.error - let results = operationTask?.results - - if let error { - Self.logger.error("Operation failed: \(error.localizedDescription)") - Task { @MainActor [weak self] in - self?.lastSyncError = SyncErrorInfo( - taskId: task.id, - uuid: task.uuid, - jobType: task.jobType, - error: error.localizedDescription - ) - } - self.retryQueuedTask() - } else { - Task { [weak self] in - guard let self else { return } - if let results { - switch results { - case .matchUuid(let response): - await self.handleMatchUuidsResponse(response) - } - } - self.handleFinishedTask(task) - } - } - } - - operationQueue.addOperation(operationTask) - } catch { - Self.logger.error("\(error.localizedDescription)") - } - } - } - - private func updateProgress( - for key: String, - value: Double - ) { - tasksProgress[key] = value - } - - private func retryQueuedTask() { - /// Retry in 5 seconds - lockQueue.asyncAfter(deadline: .now() + .seconds(5)) { - self.queueNextTask() - } - } - - private func handleFinishedTask(_ task: SyncTask) { - lockQueue.asyncAfter(deadline: .now() + .seconds(1)) { - Task { @MainActor in - _ = await self.initializeStoreTask?.result - try! await self.taskStore.finishedTask(id: task.id, jobType: task.jobType) - self.queueNextTask() - self.tasksProgress.removeValue(forKey: task.progressKey) - } - } - } - - private func handleMatchUuidsResponse(_ results: MatchUuidsResponse) async { - guard !results.conflicts.isEmpty else { return } - do { - try await applyCoreDataConflicts(results.conflicts) - try await taskStore.applyMatchUuidConflicts(results.conflicts) - } catch { - Self.logger.error("Failed to apply matchUuid conflicts: \(error.localizedDescription)") - await MainActor.run { - self.lastSyncError = SyncErrorInfo( - taskId: "", - uuid: "", - jobType: .matchUuid, - error: error.localizedDescription - ) - } - } - } - - private func applyCoreDataConflicts(_ conflicts: [ItemConflict]) async throws { - let context = dataManager.getBackgroundContext() - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - context.perform { - do { - let oldUuids = conflicts.map { $0.key } - let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() - fetchRequest.predicate = NSPredicate(format: "uuid IN %@", oldUuids) - let items = try context.fetch(fetchRequest) - let uuidMap = Dictionary(uniqueKeysWithValues: conflicts.map { ($0.key, $0.uuid) }) - for item in items { - if let newUuid = uuidMap[item.uuid] { - item.uuid = newUuid - } - } - try context.save() - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } + return await tasksRepository.hasUploadTask(for: relativePath) } } diff --git a/Shared/Services/Sync/SyncService.swift b/Shared/Services/Sync/SyncService.swift index 07db9bb68..a5fa3f13b 100644 --- a/Shared/Services/Sync/SyncService.swift +++ b/Shared/Services/Sync/SyncService.swift @@ -40,22 +40,27 @@ public protocol SyncServiceProtocol { /// Count of the currently queued sync jobs func queuedJobsCount() async -> Int - /// Observe the queued jobs count - func observeTasksCount() -> AnyPublisher /// Check if we can safely fetch the list contents func canSyncListContents(at relativePath: String?, ignoreLastTimestamp: Bool) async -> Bool /// Fetch the contents at the relativePath and override local contents with the remote repsonse func syncListContents(at relativePath: String?) async throws - /// Fetch the synced identifiers and upload new local items - /// Note: Should only be called once when the user logs in + /// The first sync: registers what the server is missing (the missing-items pass), then + /// reconciles the library root without deleting anything. Runs after signing in and on + /// coming back from a lapse func syncLibraryContents() async throws func syncBookmarksList(relativePath: String) async throws -> [SimpleBookmark]? - /// Fetch the remote synced identifiers - func fetchSyncedIdentifiers() async throws -> [String] + /// Whether the first sync has registered this device's items (after signing in, or on coming + /// back from a lapse): until then no listing may delete local items + var hasRunFirstSync: Bool { get } + + /// The missing-items pass outside the first sync: once the account becomes PRO (its books' + /// files can now go up), and weekly. Runs only when due, sync is on, the first sync has + /// run and nothing waits in the sync lane; one at a time + func scheduleMissingItemsIfNeeded() async func getRemoteFileURLs( of relativePath: String, @@ -83,15 +88,22 @@ public protocol SyncServiceProtocol { func scheduleDeleteBookmark(_ bookmark: SimpleBookmark) func scheduleUploadArtwork(relativePath: String, uuid: String) - - /// Get all queued jobs - func getAllQueuedJobs() async -> [SyncTaskReference] + + /// Upload a newly linked external resource + func scheduleExternalResourceUpload(_ resource: SyncableExternalResource, relativePath: String, uuid: String) + + /// Delete an external resource on the server + func scheduleExternalResourceDeletion(providerName: String, providerId: String, relativePath: String, uuid: String) + /// Get all queued jobs with full parameters for debugging func getAllQueuedJobsWithParams() async -> [SyncTask] /// Get last sync error information for debugging func getLastSyncError() -> SyncErrorInfo? /// Cancel all scheduled jobs func cancelAllJobs() + /// Returns once every finished download's follow-up (chapters, verification, media-server + /// upload scheduling) is done: a background wake awaits it before letting iOS suspend + func settleDownloads() async /// Cancel all scheduled jobs and wait for completion func resetAllJobs() async @@ -110,7 +122,7 @@ public protocol SyncServiceProtocol { public final class SyncService: SyncServiceProtocol, BPLogger { private var libraryService: LibrarySyncProtocol! private var accountService: AccountServiceProtocol! - private var tasksCountService: SyncTasksCountService! + private var syncQueueService: SyncQueueServiceProtocol! var jobManager: JobSchedulerProtocol! private var client: NetworkClientProtocol! /// Owned here: writes go through `updateSyncEnabled(_:)` / `logout()`, mutated on the @@ -118,7 +130,22 @@ public final class SyncService: SyncServiceProtocol, BPLogger { public private(set) var isActive: Bool = false /// In-flight logout teardown, awaited before an initial library sync re-schedules, /// so a re-login can't begin scheduling until the queue reset has finished. - private var teardownTask: Task? + /// Lock-guarded rather than @MainActor: the `.logout` sink must assign SYNCHRONOUSLY + /// (a deferred main-actor hop reopens a window where the re-login gates read nil and + /// a late resetAllJobs wipes freshly-scheduled jobs), while reads come from arbitrary + /// async contexts. + private let teardownTaskLock = NSLock() + private var _teardownTask: Task? + private var teardownTask: Task? { + get { + teardownTaskLock.lock(); defer { teardownTaskLock.unlock() } + return _teardownTask + } + set { + teardownTaskLock.lock(); defer { teardownTaskLock.unlock() } + _teardownTask = newValue + } + } /// Dictionary holding the initiating item relative path as key and the download tasks as value private var downloadTasksDictionary = [String: [URLSessionTask]]() @@ -136,26 +163,65 @@ public final class SyncService: SyncServiceProtocol, BPLogger { public var downloadErrorPublisher = PassthroughSubject<(String, Error), Never>() /// Background URL session to handle downloading synced items private var downloadURLSession: BPDownloadURLSession! + /// Finished downloads still being finalized, so a background wake can wait for them + private let finalizeLock = NSLock() + private var finalizeTasks = [UUID: Task]() private var provider: NetworkProvider! + /// Only the phone runs the missing-items pass: the watch has downloaded files of its own + /// and must never upload them + private var runsMissingItemsPass = false + /// One pass at a time: an account update and a list refresh can ask together + private let missingItemsPassLock = NSLock() + private var isRunningMissingItemsPass = false + /// Where the first-sync flag and the pass's schedule live (injected by tests) + private var defaults: UserDefaults = .standard + /// Bumped (under `missingItemsPassLock`) whenever sync goes off: a pass that began earlier + /// can't tell a new session from its own by `isActive` alone if sync came back meanwhile + private var syncSession = 0 + + public var hasRunFirstSync: Bool { + defaults.bool(forKey: Constants.UserDefaults.hasScheduledLibraryContents) + } + private var disposeBag = Set() public init() {} + /// Where an external item downloads from — the same resolution PlaybackService uses to + /// stream it, so the two paths cannot disagree about server, URL or auth headers. + private var streamResolver: ExternalStreamResolving! + public func setup( isActive: Bool, libraryService: LibrarySyncProtocol, accountService: AccountServiceProtocol, + syncQueueService: SyncQueueServiceProtocol, client: NetworkClientProtocol = NetworkClient(), - dataManager: DataManager + streamResolver: ExternalStreamResolving = ExternalStreamResolver(), + runsMissingItemsPass: Bool = false, + userDefaults: UserDefaults = .standard ) { self.isActive = isActive + self.runsMissingItemsPass = runsMissingItemsPass + self.defaults = userDefaults + self.streamResolver = streamResolver self.libraryService = libraryService self.accountService = accountService - let tasksDataManager = TasksDataManager() - self.tasksCountService = SyncTasksCountService(tasksDataManager: tasksDataManager) - self.jobManager = SyncJobScheduler(tasksDataManager: tasksDataManager, dataManager: dataManager) + // Signed in but not syncing is a lapse too, usually one that happened while the app was + // closed (the cached entitlement expired), with no transition for updateSyncEnabled to + // see. A paying subscriber whose cached read is stale lands here as well: harmless, the + // first sync never clears queued work and only runs once the sync lane is empty + if runsMissingItemsPass, !isActive, accountService.getAccountId() != nil { + defaults.set(false, forKey: Constants.UserDefaults.hasScheduledLibraryContents) + } + self.syncQueueService = syncQueueService + self.jobManager = SyncJobScheduler(tasksRepository: syncQueueService.taskContainer) + // The queue holds the server lanes until told otherwise: a lapsed account's persisted + // tasks stay put (a fresh install's first RevenueCat fetch may still flip it on) but + // never hit the server, which would reject them forever + syncQueueService.setServerLanesEnabled(isActive) self.client = client self.provider = NetworkProvider(client: client) @@ -202,7 +268,8 @@ public final class SyncService: SyncServiceProtocol, BPLogger { .sink(receiveValue: { [weak self] _ in /// Covers every logout path (iOS sign-out, account deletion, Watch), since /// they all post `.logout`. Tears down the queue, the flag, and `isActive`. - /// Held in `teardownTask` so a re-login's initial sync awaits it first. + /// Held in `teardownTask` — assigned SYNCHRONOUSLY (lock-guarded) so a + /// re-login's initial sync can never observe a pre-assignment nil. self?.teardownTask = Task { await self?.logout() } }) .store(in: &disposeBag) @@ -214,6 +281,7 @@ public final class SyncService: SyncServiceProtocol, BPLogger { .sink(receiveValue: { [weak self] _ in guard let self, self.accountService.hasAccount() else { return } self.updateSyncEnabled(self.accountService.hasSyncEnabled()) + self.noteProAccess() }) .store(in: &disposeBag) @@ -233,10 +301,6 @@ public final class SyncService: SyncServiceProtocol, BPLogger { return await jobManager.queuedJobsCount() } - public func observeTasksCount() -> AnyPublisher { - return tasksCountService.observeTasksCount() - } - public func canSyncListContents(at relativePath: String?, ignoreLastTimestamp: Bool) async -> Bool { guard isActive else { Self.logger.trace("Sync is not enabled") @@ -285,33 +349,43 @@ public final class SyncService: SyncServiceProtocol, BPLogger { guard isActive else { throw BookPlayerError.networkError("Sync is not enabled") } + // Its pass queues this device's files for upload: never from the watch + guard runsMissingItemsPass else { + throw BookPlayerError.runtimeError("The first sync runs on the phone only") + } /// Wait for any in-flight logout teardown to finish before scheduling, so a fast /// logout→login can't have a late `resetAllJobs()` wipe freshly-scheduled jobs. await teardownTask?.value - if await queuedJobsCount() > 0 { - Self.logger.trace("Clearing orphaned tasks before initial library sync") - await resetAllJobs() + // Work queued before sync went off is held, never cleared (a paying subscriber can read + // lapsed at launch): it runs first, so the server has it before it's asked what it's + // missing. The flag stays off meanwhile, and the next root refresh runs this again + guard await queuedJobsCount() == 0 else { + Self.logger.trace("First sync waits for the queued sync tasks") + return } - - Self.logger.trace("Fetching synced library identifiers") - - let fetchedIdentifiers = try await fetchSyncedIdentifiers() - - if let itemsToUpload = await libraryService.getItemsToSync(remoteIdentifiers: fetchedIdentifiers), - !itemsToUpload.isEmpty - { - Self.logger.trace("Scheduling upload tasks") - await handleItemsToUpload(itemsToUpload) + guard beginMissingItemsPass() else { return } + defer { endMissingItemsPass() } + let session = currentSyncSession() + + Self.logger.trace("Registering what the server is missing") + + // By uuid, never by comparing paths: a path that went stale on this device (after a + // lapse, the stalest case) would make the server move its item back + let couldQueueFiles = try await runMissingItemsPass(startsContinuedUploads: true, session: session) + // Sync went off meanwhile (and maybe came back): its teardown wiped what this queued + guard isStillSyncSession(session) else { return } + // Only once its books' files could be queued: an account update may not have given the + // queue its PRO policy yet, and the next root refresh then runs the pass again + if couldQueueFiles { + defaults.set(false, forKey: Constants.UserDefaults.missingItemsPassPending) } let response = try await fetchContents(at: nil) - UserDefaults.standard.set( - true, - forKey: Constants.UserDefaults.hasScheduledLibraryContents - ) + // Checked and written in one step with the session's end, which clears the flag + guard markFirstSyncDone(in: session) else { return } try await processContentsResponse(response, parentFolder: nil, canDelete: false) } @@ -339,8 +413,9 @@ public final class SyncService: SyncServiceProtocol, BPLogger { if let lastItemPlayed = response.lastItemPlayed { filteredItemsDict.removeValue(forKey: lastItemPlayed.relativePath) } - await libraryService.updateInfo(for: filteredItemsDict, parentFolder: parentFolder) + await libraryService.updateInfo(for: filteredItemsDict, parentFolder: parentFolder) + await libraryService.storeNewItems(from: completeItemsDict, parentFolder: parentFolder) if canDelete { @@ -405,12 +480,6 @@ public final class SyncService: SyncServiceProtocol, BPLogger { return libraryService.getBookmarks(of: .user, relativePath: relativePath) } - public func fetchSyncedIdentifiers() async throws -> [String] { - let response: IdentifiersResponse = try await self.provider.request(.syncedIdentifiers) - - return response.content - } - func fetchContents(at relativePath: String?) async throws -> ContentsResponse { let path: String if let relativePath = relativePath { @@ -452,8 +521,30 @@ public final class SyncService: SyncServiceProtocol, BPLogger { } public func downloadRemoteFiles(for item: SimpleLibraryItem) async throws { - let remoteURLs = try await getRemoteFileURLs(of: item.relativePath, for: item.uuid, type: item.type) - + var remoteURLs: [RemoteFileURL] = [] + var isExternalItem = false + // streamingResource, not first(where:) over an unordered set: it is provider-filtered. + // The API's markExternalSourceUploaded marks EVERY provider row 'downloaded' for an + // item, so a dual-linked book's Hardcover row can pass a syncStatus check — and Hardcover + // has no files. Picking it here threw integration_error_missing_connection on a book + // whose Jellyfin connection was fine. + if item.type == .book, let external = item.externalResources?.streamingResource { + isExternalItem = true + + if let source = streamResolver.streamSource(for: external) { + remoteURLs = [ + RemoteFileURL( + url: source.url, + relativePath: item.relativePath, + type: .book, + headers: source.headers + ) + ] + } + } else { + remoteURLs = try await getRemoteFileURLs(of: item.relativePath, for: item.uuid, type: item.type) + } + let processedFolderURL = DataManager.getProcessedFolderURL() let folderURLs = remoteURLs.filter({ $0.type != .book }) @@ -465,22 +556,47 @@ public final class SyncService: SyncServiceProtocol, BPLogger { } } + // An external item that produced no URL means its media-server connection isn't + // resolvable on this device (deleted / never added) — completing silently with zero + // download tasks strands the user with no spinner and no explanation. + if isExternalItem, remoteURLs.isEmpty { + throw BookPlayerError.runtimeError("integration_error_missing_connection".localized) + } + let bookURLs = remoteURLs.filter({ $0.type == .book }) var tasks = [URLSessionTask]() for remoteURL in bookURLs { let localURL = processedFolderURL.appendingPathComponent(remoteURL.relativePath) + let downloadUrl = remoteURL.url guard !FileManager.default.fileExists(atPath: localURL.path) else { continue } + + if let headers = remoteURL.headers, !headers.isEmpty { + var request = URLRequest(url: downloadUrl) + // Forward EVERY header — the dict deliberately includes the connection's custom + // headers (reverse-proxy gates like Cloudflare Access), not just Authorization. + for (field, value) in headers { + request.setValue(value, forHTTPHeaderField: field) + } + + let task = await provider.client.download( + request: request, + taskDescription: remoteURL.relativePath, + session: downloadURLSession.backgroundSession + ) - let task = await provider.client.download( - url: remoteURL.url, - taskDescription: remoteURL.relativePath, - session: downloadURLSession.backgroundSession - ) + tasks.append(task) + } else { + let task = await provider.client.download( + url: downloadUrl, + taskDescription: remoteURL.relativePath, + session: downloadURLSession.backgroundSession + ) - tasks.append(task) + tasks.append(task) + } } downloadTasksDictionary[item.relativePath] = tasks @@ -502,8 +618,36 @@ public final class SyncService: SyncServiceProtocol, BPLogger { } } - public func getAllQueuedJobs() async -> [SyncTaskReference] { - return await jobManager.getAllQueuedJobs() + public func scheduleExternalResourceUpload( + _ resource: SyncableExternalResource, + relativePath: String, + uuid: String + ) { + guard isActive else { return } + + Task { + await jobManager.scheduleExternalResourceUpload( + for: resource, + itemOrigin: LibraryItemRef(relativePath: relativePath, uuid: uuid) + ) + } + } + + public func scheduleExternalResourceDeletion( + providerName: String, + providerId: String, + relativePath: String, + uuid: String + ) { + guard isActive else { return } + + Task { + await jobManager.scheduleDeleteExternalResource( + providerName: providerName, + providerId: providerId, + itemOrigin: LibraryItemRef(relativePath: relativePath, uuid: uuid) + ) + } } public func getAllQueuedJobsWithParams() async -> [SyncTask] { @@ -511,7 +655,7 @@ public final class SyncService: SyncServiceProtocol, BPLogger { } public func getLastSyncError() -> SyncErrorInfo? { - return jobManager.lastSyncError + return syncQueueService.lastSyncError } public func cancelAllJobs() { @@ -530,8 +674,16 @@ public final class SyncService: SyncServiceProtocol, BPLogger { Task { @MainActor in guard self.isActive != enabled else { return } self.isActive = enabled + self.syncQueueService.setServerLanesEnabled(enabled) if !enabled { + // A lapse makes the return a first sync: what's imported meanwhile never reaches the + // server, and a plain listing would delete it (it isn't on the server) + self.endSyncSession(resettingFirstSync: self.runsMissingItemsPass) self.cancelAllJobs() + // Clearing the persisted rows isn't enough (develop parity): an operation + // already executing keeps uploading after the lapse without this. Scoped so + // in-flight externalUpdate pushes survive — they run on every tier. + self.syncQueueService.cancelServerQueueOperations() } } } @@ -540,11 +692,20 @@ public final class SyncService: SyncServiceProtocol, BPLogger { /// clears the persisted task queue, and resets the "scheduled library contents" flag /// so the next login runs a fresh initial sync from an empty queue. Idempotent. public func logout() async { - await MainActor.run { self.isActive = false } - UserDefaults.standard.set( - false, - forKey: Constants.UserDefaults.hasScheduledLibraryContents - ) + // Same main-actor hop as the flag: a fast re-login's updateSyncEnabled(true) runs on + // main, so a gate write outside this block could land after it and hold the lanes + await MainActor.run { + self.isActive = false + self.syncQueueService.setServerLanesEnabled(false) + } + endSyncSession(resettingFirstSync: true) + for key in [ + Constants.UserDefaults.missingItemsPassLastRun, + Constants.UserDefaults.missingItemsPassPending, + Constants.UserDefaults.lastKnownProAccess, + ] { + defaults.removeObject(forKey: key) + } await resetAllJobs() } } @@ -585,22 +746,36 @@ extension SyncService { } extension SyncService { - func handleItemsToUpload(_ items: [SyncableItem]) async { + /// - Parameter announcesUploads: whether queued book files may start the continued task + func handleItemsToUpload(_ items: [SyncableItem], announcesUploads: Bool = true) async { for item in items { await jobManager.scheduleLibraryItemUploadJob(for: item) + + if let externalResources = item.externalResources { + for externalResource in externalResources { + await jobManager.scheduleExternalResourceUpload(for: externalResource, itemOrigin: LibraryItemRef(relativePath: item.relativePath, uuid: item.uuid)) + } + } } - /// Handle bookmarks in separate loop, as the viewContext can be unreliable + /// Bookmarks after every item, read in batches on a background context: a first sync + /// registers the whole library, and this runs off main + let bookmarksByPath = await libraryService.getUserBookmarks(forItemsAt: items.map(\.relativePath)) for item in items { - if let bookmarks = libraryService.getBookmarks(of: .user, relativePath: item.relativePath) { - for bookmark in bookmarks { - await jobManager.scheduleSetBookmarkJob( - with: bookmark.relativePath, - time: floor(bookmark.time), - note: bookmark.note, - for: item.uuid - ) - } + for bookmark in bookmarksByPath[item.relativePath] ?? [] { + await jobManager.scheduleSetBookmarkJob( + with: bookmark.relativePath, + time: floor(bookmark.time), + note: bookmark.note, + for: item.uuid + ) + } + } + + // Media-server books have no file to upload from here (theirs goes up once downloaded) + if announcesUploads, items.contains(where: { $0.type == .book && $0.mediaServerProviderName == nil }) { + await MainActor.run { + NotificationCenter.default.post(name: .bookUploadsQueued, object: nil) } } } @@ -743,13 +918,25 @@ extension SyncService { /// download that already finished: nothing to move, nothing to announce. guard let movedFileURL else { return } - Task { - await self.finalizeDownloadedFile( - relativePath: relativePath, - fileURL: movedFileURL, - startingItemPath: startingItemPath, - parentFolderPath: parentFolderPath - ) + let id = UUID() + // Stored under the lock it removes itself with, so an instant finish can't beat the insert + finalizeLock.withLock { + finalizeTasks[id] = Task { + await self.finalizeDownloadedFile( + relativePath: relativePath, + fileURL: movedFileURL, + startingItemPath: startingItemPath, + parentFolderPath: parentFolderPath + ) + _ = self.finalizeLock.withLock { self.finalizeTasks.removeValue(forKey: id) } + } + } + } + + public func settleDownloads() async { + let tasks = finalizeLock.withLock { Array(finalizeTasks.values) } + for task in tasks { + await task.value } } @@ -786,6 +973,27 @@ extension SyncService { DispatchQueue.main.async { self.downloadCompletedPublisher.send((relativePath, startingItemPath, parentFolderPath)) } + + guard + let snapshot = libraryService.getItemResourcesSnapshot(for: relativePath), + let externalResource = snapshot.resources.first(where: { + $0.syncStatus == ExternalResource.SyncStatus.stream.rawValue + }) + else { return } + + let externalSyncItem = SyncableExternalResource( + providerName: externalResource.providerName, + providerId: externalResource.providerId, + syncStatus: externalResource.syncStatus, + lastSyncedAt: nil, + processedFile: true, + hostId: externalResource.hostId + ) + await libraryService.updateExternalResource(for: externalSyncItem) + // Deliberately NOT gated on `isActive`: external-resource sync is tier-independent by + // design (the reference lives in the DB for every tier and the server validates + // entitlements) — matching getDownloadState, which permits these downloads when !isActive. + await jobManager.scheduleResourceToDownload(with: relativePath, for: snapshot.uuid) } /// Backstop against truncated/botched downloads that finish without surfacing a @@ -919,9 +1127,10 @@ extension SyncService { /// Get download state of an item public func getDownloadState(for item: SimpleLibraryItem) -> DownloadState { - /// Only process if subscription is active - guard isActive else { return .downloaded } - + let hasExternalResources = !(item.externalResources?.isEmpty ?? true) + /// Only process if subscription is active or it has external resources + guard isActive || hasExternalResources else { return .downloaded } + if downloadTasksDictionary[item.relativePath]?.isEmpty == false { return .downloading(progress: calculateDownloadProgress(with: item.relativePath)) } @@ -945,3 +1154,200 @@ extension SyncService { } } } + +// MARK: - Missing-items pass + +extension SyncService { + /// How often the pass runs without a tier change + static let missingItemsPassInterval: TimeInterval = 7 * 24 * 60 * 60 + + /// The missing-items pass (bookplayer-api docs/multipart-uploads.md): asks the server about + /// every uuid in the local library, registers the items it has never seen like an import + /// and, with S3 access, queues the files of books it holds without one. By uuid, never by + /// path, so a path that went stale on this device can't move anything back. + /// - Parameters: + /// - startsContinuedUploads: whether queued book files may start the continued task (the + /// first sync and a tier change; the weekly run never does) + /// - session: the sync session the caller began in (its own, when nil) + /// - Returns: whether the tier could queue book files (a pending tier change is done) + @discardableResult + func runMissingItemsPass(startsContinuedUploads: Bool, session: Int? = nil) async throws -> Bool { + let session = session ?? currentSyncSession() + guard let localUuids = await libraryService.fetchAllUuids() else { + throw BookPlayerError.runtimeError("Couldn't read the library for the missing-items pass") + } + let status: ItemsStatusResponse = try await provider.request(.itemsStatus(uuids: localUuids)) + // Sync went off while the server answered (and maybe came back): its teardown already ran + guard isStillSyncSession(session) else { return false } + + // A book with an upload queued (parked included) isn't lost: leave it to that task + let queuedUploads = Set(await syncQueueService.taskContainer.getUploadCandidates().map(\.task.uuid)) + + let unknownUuids = status.unknown.filter { !queuedUploads.contains($0) } + var unknownItems = [SyncableItem]() + if !unknownUuids.isEmpty { + guard let items = await libraryService.getSyncableItems(forUuids: unknownUuids) else { + throw BookPlayerError.runtimeError("Couldn't read the items the server is missing") + } + unknownItems = try await matchUuids(of: items) + guard isStillSyncSession(session) else { return false } + await handleItemsToUpload(unknownItems, announcesUploads: false) + } + + // A logout during the registrations: don't queue into the next account's session + guard isStillSyncSession(session) else { return false } + // Read once: the same answer decides whether files are queued and whether a pending tier + // change is done + let canQueueFiles = syncQueueService.accessPolicy[.uploadFile] == true + var queuedFiles = 0 + if canQueueFiles, !status.unsynced.isEmpty { + guard let books = await libraryService.getSyncableItems(forUuids: status.unsynced) else { + throw BookPlayerError.runtimeError("Couldn't read the books the server has no file for") + } + queuedFiles = await syncQueueService.scheduleMissingFileUploads(books.compactMap(Self.missingFileUpload(for:))) + } + + if startsContinuedUploads, + queuedFiles > 0 || unknownItems.contains(where: { $0.type == .book && $0.mediaServerProviderName == nil }) + { + await MainActor.run { + NotificationCenter.default.post(name: .bookUploadsQueued, object: nil) + } + } + + // After a logout, the next account starts with no run on record + guard isStillSyncSession(session) else { return false } + defaults.set(Date().timeIntervalSince1970, forKey: Constants.UserDefaults.missingItemsPassLastRun) + return canQueueFiles + } + + /// Matches the items' uuids by path BEFORE anything registers them: the server may already + /// hold a row at an item's path under no uuid (a legacy row: the server takes ours) or + /// another one (the same file imported on another device: this device adopts it, in the + /// library and every queued task). `PUT /` at such a path would answer with that row without + /// storing our uuid. Done inline, not as a queued job, so every task registered afterwards + /// already carries the uuid the server knows. Returns the items as they now are. + private func matchUuids(of items: [SyncableItem]) async throws -> [SyncableItem] { + var conflicts = [ItemConflict]() + let limit = SyncJobScheduler.matchUuidsBatchLimit + for start in stride(from: 0, to: items.count, by: limit) { + let batch = items[start.. MissingFileUpload? { + guard item.type == .book, item.mediaServerProviderName == nil else { return nil } + let fileURL = DataManager.getProcessedFolderURL().appendingPathComponent(item.relativePath) + guard + let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true, + let size = values.fileSize, + Int64(size) <= FileUploadOperation.maxFileSize + else { return nil } + return MissingFileUpload(uuid: item.uuid, relativePath: item.relativePath, fileURL: fileURL) + } + + public func scheduleMissingItemsIfNeeded() async { + guard runsMissingItemsPass, isActive else { return } + // Until then, the first sync is the pass + guard defaults.bool(forKey: Constants.UserDefaults.hasScheduledLibraryContents) else { return } + guard isMissingItemsPassDue() else { return } + + guard beginMissingItemsPass() else { return } + defer { endMissingItemsPass() } + + await teardownTask?.value + let session = currentSyncSession() + // A tier change that arrived during a weekly run runs right after it, not a week later + while isMissingItemsPassDue() { + // Off (a logout or lapse) the pass stops without recording a run: stop with it. Queued + // changes haven't reached the server yet: a queued import would come back unknown and be + // registered twice + guard isStillSyncSession(session), hasRunFirstSync, await jobManager.queuedJobsCount() == 0 else { return } + let isPending = defaults.bool(forKey: Constants.UserDefaults.missingItemsPassPending) + do { + let couldQueueFiles = try await runMissingItemsPass(startsContinuedUploads: isPending, session: session) + guard isPending else { continue } + guard couldQueueFiles else { return } + defaults.set(false, forKey: Constants.UserDefaults.missingItemsPassPending) + } catch { + Self.logger.error("Missing-items pass failed: \(error.localizedDescription)") + return + } + } + } + + private func isMissingItemsPassDue() -> Bool { + let lastRun = defaults.double(forKey: Constants.UserDefaults.missingItemsPassLastRun) + return defaults.bool(forKey: Constants.UserDefaults.missingItemsPassPending) + || Date().timeIntervalSince1970 - lastRun >= Self.missingItemsPassInterval + } + + /// LITE → PRO: the books registered without their file can now upload, so the next pass is + /// due at once (and starts the continued task). Leaving PRO drops a pass still pending. The + /// first reading (an app update, a fresh install, a sign-in) is no tier change: the first + /// sync and the weekly run cover what's missing + func noteProAccess() { + guard runsMissingItemsPass else { return } + let isPro = accountService.getAccessLevel() == .pro + if let wasPro = defaults.object(forKey: Constants.UserDefaults.lastKnownProAccess) as? Bool, wasPro != isPro { + defaults.set(isPro, forKey: Constants.UserDefaults.missingItemsPassPending) + } + defaults.set(isPro, forKey: Constants.UserDefaults.lastKnownProAccess) + Task { await self.scheduleMissingItemsIfNeeded() } + } + + private func beginMissingItemsPass() -> Bool { + missingItemsPassLock.withLock { + guard !isRunningMissingItemsPass else { return false } + isRunningMissingItemsPass = true + return true + } + } + + private func endMissingItemsPass() { + missingItemsPassLock.withLock { isRunningMissingItemsPass = false } + } + + private func currentSyncSession() -> Int { + missingItemsPassLock.withLock { syncSession } + } + + private func isStillSyncSession(_ session: Int) -> Bool { + isActive && missingItemsPassLock.withLock { syncSession == session } + } + + /// Sync went off (a logout, a lapse): the session ends and, where asked, the first-sync + /// flag clears in the same step, so a pass from the old session can't mark itself done after + private func endSyncSession(resettingFirstSync: Bool) { + missingItemsPassLock.withLock { + syncSession += 1 + if resettingFirstSync { + defaults.set(false, forKey: Constants.UserDefaults.hasScheduledLibraryContents) + } + } + } + + private func markFirstSyncDone(in session: Int) -> Bool { + missingItemsPassLock.withLock { + guard isActive, syncSession == session else { return false } + defaults.set(true, forKey: Constants.UserDefaults.hasScheduledLibraryContents) + return true + } + } +} diff --git a/Shared/Services/Sync/SyncTasksCountService.swift b/Shared/Services/Sync/SyncTasksCountService.swift deleted file mode 100644 index ce07fe9c9..000000000 --- a/Shared/Services/Sync/SyncTasksCountService.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// SyncTasksCountService.swift -// BookPlayer -// -// Created by Gianni Carlo on 27/2/24. -// Copyright © 2024 BookPlayer LLC. All rights reserved. -// - -import Combine -import Foundation - -public protocol SyncTasksCountServiceProtocol { - func observeTasksCount() -> AnyPublisher -} - -public class SyncTasksCountService: SyncTasksCountServiceProtocol { - private var tasksCountPublisher = CurrentValueSubject(nil) - private let tasksDataManager: TasksDataManager - - public init(tasksDataManager: TasksDataManager) { - self.tasksDataManager = tasksDataManager - } - - public func observeTasksCount() -> AnyPublisher { - return tasksDataManager.observeTasksCount() - } -} diff --git a/Shared/Services/Sync/SyncTasksStorage.swift b/Shared/Services/Sync/SyncTasksStorage.swift deleted file mode 100644 index 95b1b22fe..000000000 --- a/Shared/Services/Sync/SyncTasksStorage.swift +++ /dev/null @@ -1,302 +0,0 @@ -// -// SyncTasksStorage.swift -// BookPlayer -// -// Created by Gianni Carlo on 13/1/24. -// Copyright © 2024 BookPlayer LLC. All rights reserved. -// - -import Combine -import Foundation -import SwiftData - -/// Persist jobs using SwiftData -public actor SyncTasksStorage: ModelActor { - nonisolated public let modelContainer: ModelContainer - nonisolated public let modelExecutor: any ModelExecutor - - private let tasksDataManager: TasksDataManager - - init(tasksDataManager: TasksDataManager) throws { - self.modelContainer = tasksDataManager.container - let modelContext = ModelContext(tasksDataManager.container) - modelContext.autosaveEnabled = true - self.modelExecutor = DefaultSerialModelExecutor(modelContext: modelContext) - self.tasksDataManager = tasksDataManager - } - - public func appendTask(parameters: [String: Any]) async throws { - guard - let taskId = parameters["id"] as? String, - let rawJobType = parameters["jobType"] as? String, - let jobType = SyncJobType(rawValue: rawJobType), - let uuid = parameters["uuid"] as? String, - let relativePath = parameters["relativePath"] as? String - else { - throw BookPlayerError.runtimeError("Missing id or job type when creating task") - } - - let context = modelContext - - // Get or create the tasks container - let descriptor = FetchDescriptor() - let containers = try context.fetch(descriptor) - let tasksContainer = containers.first ?? SyncTasksContainer() - - if containers.isEmpty { - context.insert(tasksContainer) - } - - // Coalesce matchUuid tasks: if one is already queued and is NOT the task currently - // in flight (head of the ordered queue), merge new relativePath→uuid pairs into it. - // Prefer existing values so we never overwrite uuids that other queued tasks or - // Core Data are already referencing. The in-flight task is skipped because its - // parameter snapshot has already been read by `getNextTask`, so mutations would - // be silently dropped when the task finishes and gets deleted. - if jobType == .matchUuid, - let newUuidsDict = parameters["uuids"] as? [String: String], - let headTaskID = tasksContainer.orderedTasks.first?.taskID, - let candidateTask = try context - .fetch(FetchDescriptor()) - .first(where: { $0.id != headTaskID }) - { - var merged = candidateTask.uuids - for (path, uuid) in newUuidsDict where merged[path] == nil { - merged[path] = uuid - } - candidateTask.uuids = merged - try context.save() - tasksDataManager.notifyTasksChanged(context: context) - return - } - - // Check for update task optimization - if jobType == .update, - tasksContainer.tasks.count > 1, - let existingTask = try context.fetch(FetchDescriptor()) - .last(where: { $0.uuid == uuid }) - { - - var parameters = parameters - parameters["id"] = existingTask.id - - // Update existing task - tasksDataManager.updateTaskModel(existingTask, with: parameters) - - } else { - // Create new task object - tasksDataManager.createTaskModel(for: jobType, with: parameters, in: modelContext) - - let nextPosition = (tasksContainer.tasks.map(\.position).max() ?? -1) + 1 - // Create task reference - let taskReference = SyncTaskReferenceModel( - uuid: uuid, - relativePath: relativePath, - taskID: taskId, - jobType: jobType, - position: nextPosition - ) - - // Add to container - tasksContainer.tasks.append(taskReference) - taskReference.container = tasksContainer - } - - try context.save() - - tasksDataManager.notifyTasksChanged(context: context) - } - - public func getNextTask() async throws -> SyncTask? { - let descriptor = FetchDescriptor() - let containers = try modelContext.fetch(descriptor) - - guard let tasksContainer = containers.first, - let firstTask = tasksContainer.orderedTasks.first - else { - return nil - } - - guard - let storedObject = tasksDataManager.getTaskModel( - with: firstTask.taskID, - jobType: firstTask.jobType, - in: modelContext - ) - else { - // Remove invalid task reference - tasksContainer.tasks = tasksContainer.tasks.filter { $0.taskID != firstTask.taskID } - try modelContext.save() - return nil - } - - return SyncTask( - id: firstTask.taskID, - uuid: firstTask.uuid, - relativePath: firstTask.relativePath, - jobType: firstTask.jobType, - parameters: storedObject.toDictionaryPayload() - ) - } - - public func finishedTask(id: String, jobType: SyncJobType) async throws { - let descriptor = FetchDescriptor() - let containers = try modelContext.fetch(descriptor) - - guard let tasksContainer = containers.first else { return } - - // Delete the actual task object - try tasksDataManager.deleteTaskModel( - with: id, - jobType: jobType, - context: modelContext - ) - - // Remove task reference - tasksContainer.tasks = tasksContainer.tasks.filter { $0.taskID != id } - - try tasksDataManager.deleteReferenceModel( - with: id, - jobType: jobType, - context: modelContext - ) - - try modelContext.save() - - tasksDataManager.notifyTasksChanged(context: modelContext) - } - - public func getAllTasks(progress: [String: Double]) async -> [SyncTaskReference] { - do { - let descriptor = FetchDescriptor() - let containers = try modelContext.fetch(descriptor) - - guard let tasksContainer = containers.first else { return [] } - - return tasksContainer.orderedTasks.map { task in - let key = SyncProgressKey.resolve(uuid: task.uuid, relativePath: task.relativePath) - return SyncTaskReference( - id: task.taskID, - uuid: task.uuid, - relativePath: task.relativePath, - jobType: task.jobType, - progress: progress[key] ?? 0.0 - ) - } - - } catch { - return [] - } - } - - public func getAllTasksWithParams() async -> [SyncTask] { - do { - let descriptor = FetchDescriptor() - let containers = try modelContext.fetch(descriptor) - - guard let tasksContainer = containers.first else { return [] } - - return tasksContainer.orderedTasks.compactMap { taskRef in - guard - let storedObject = tasksDataManager.getTaskModel( - with: taskRef.taskID, - jobType: taskRef.jobType, - in: modelContext - ) - else { - return nil - } - - return SyncTask( - id: taskRef.taskID, - uuid: taskRef.uuid, - relativePath: taskRef.relativePath, - jobType: taskRef.jobType, - parameters: storedObject.toDictionaryPayload() - ) - } - - } catch { - return [] - } - } - - public func getTasksCount() -> Int { - tasksDataManager.getTasksCount() - } - - public func clearAll() async throws { - try tasksDataManager.deleteAllTasks(with: modelContext) - } - - /// Rewrites task reference and task model uuids for each conflict returned by `matchUuid`. - /// Each conflict maps a locally-known uuid (`key`) to the uuid the server wants the client - /// to adopt (`uuid`). Runs on the actor's serial executor. - public func applyMatchUuidConflicts(_ conflicts: [ItemConflict]) throws { - for conflict in conflicts { - let oldUuid = conflict.key - let newUuid = conflict.uuid - let refs = try modelContext.fetch( - FetchDescriptor(predicate: #Predicate { $0.uuid == oldUuid }) - ) - for ref in refs { - ref.uuid = newUuid - let taskId = ref.taskID - switch ref.jobType { - case .upload: - if let task = try modelContext.fetch( - FetchDescriptor(predicate: #Predicate { $0.id == taskId }) - ).first { task.uuid = newUuid } - case .update: - if let task = try modelContext.fetch( - FetchDescriptor(predicate: #Predicate { $0.id == taskId }) - ).first { task.uuid = newUuid } - case .move: - if let task = try modelContext.fetch( - FetchDescriptor(predicate: #Predicate { $0.id == taskId }) - ).first { task.uuid = newUuid } - case .renameFolder: - if let task = try modelContext.fetch( - FetchDescriptor(predicate: #Predicate { $0.id == taskId }) - ).first { task.uuid = newUuid } - case .delete, .shallowDelete: - if let task = try modelContext.fetch( - FetchDescriptor(predicate: #Predicate { $0.id == taskId }) - ).first { task.uuid = newUuid } - case .setBookmark: - if let task = try modelContext.fetch( - FetchDescriptor(predicate: #Predicate { $0.id == taskId }) - ).first { task.uuid = newUuid } - case .deleteBookmark: - if let task = try modelContext.fetch( - FetchDescriptor(predicate: #Predicate { $0.id == taskId }) - ).first { task.uuid = newUuid } - case .uploadArtwork: - if let task = try modelContext.fetch( - FetchDescriptor(predicate: #Predicate { $0.id == taskId }) - ).first { task.uuid = newUuid } - case .matchUuid: - break - } - } - } - try modelContext.save() - } - - /// Check if there's an upload task queued for the item - public func hasUploadTask(for relativePath: String) async -> Bool { - do { - let descriptor = FetchDescriptor( - predicate: #Predicate { task in - task.relativePath == relativePath - } - ) - - let tasks = try modelContext.fetch(descriptor) - return !tasks.isEmpty - - } catch { - return false - } - } -} diff --git a/Shared/Services/SyncQueue/AsyncOperation.swift b/Shared/Services/SyncQueue/AsyncOperation.swift new file mode 100644 index 000000000..7b92c5b4f --- /dev/null +++ b/Shared/Services/SyncQueue/AsyncOperation.swift @@ -0,0 +1,88 @@ +// +// AsyncOperation.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 23/3/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +class AsyncOperation: Operation, @unchecked Sendable { + enum State: String { + case ready, executing, finished + fileprivate var keyPath: String { return "is\(rawValue.capitalized)" } + } + + /// `state` is read by the OperationQueue and written from both the queue thread (`start()`) + /// and the subclasses' detached Tasks (`finish()`), so access is lock-guarded — the classic + /// async-Operation footgun. KVO notifications fire outside the lock. + private let stateLock = NSLock() + private var _state = State.ready + var state: State { + get { + stateLock.lock(); defer { stateLock.unlock() } + return _state + } + set { + let oldValue: State + stateLock.lock() + oldValue = _state + stateLock.unlock() + willChangeValue(forKey: newValue.keyPath) + willChangeValue(forKey: oldValue.keyPath) + stateLock.lock() + _state = newValue + stateLock.unlock() + didChangeValue(forKey: oldValue.keyPath) + didChangeValue(forKey: newValue.keyPath) + } + } + + var onProgress: (@Sendable (Double) -> Void)? + /// Written from the subclasses' async Tasks / completion sinks, read from the queue + /// thread in SyncQueueService's completionBlock — lock-guarded like `state` so the + /// read has a happens-before edge with the write. + private var _didSucceed = false + var didSucceed: Bool { + get { + stateLock.lock(); defer { stateLock.unlock() } + return _didSucceed + } + set { + stateLock.lock(); defer { stateLock.unlock() } + _didSucceed = newValue + } + } + override var isReady: Bool { super.isReady && state == .ready } + override var isExecuting: Bool { state == .executing } + override var isFinished: Bool { state == .finished } + override var isAsynchronous: Bool { true } + + override func start() { + guard !isCancelled else { + // Route through finish() so didFinish is set — writing state directly would + // let a later stray finish() fire the isFinished KVO pair a second time + finish() + return + } + state = .executing + main() + } + + // Subclasses must call this when their async work is totally done. + // Idempotent: cancel() and a racing terminal completion callback can BOTH reach here + // (Combine won't interrupt a sink already mid-execution), and the isFinished KVO pair + // must fire exactly once per operation or the OperationQueue misbehaves. + private var didFinish = false + func finish() { + stateLock.lock() + if didFinish { + stateLock.unlock() + return + } + didFinish = true + stateLock.unlock() + state = .finished + } +} diff --git a/Shared/Services/SyncQueue/ExternalUpdateProgressOperation.swift b/Shared/Services/SyncQueue/ExternalUpdateProgressOperation.swift new file mode 100644 index 000000000..2d2edfe4e --- /dev/null +++ b/Shared/Services/SyncQueue/ExternalUpdateProgressOperation.swift @@ -0,0 +1,143 @@ +// +// ExternalUpdateProgressOperation.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 23/3/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation +import Get + +class ExternalUpdateProgressOperation: AsyncOperation, BPLogger, @unchecked Sendable { + + let providerName: String + let providerItemId: String + let positionTicks: Int + let percentCompleted: Double + let hostId: String? + + init(providerName: String, providerItemId: String, positionTicks: Int, percentCompleted: Double, hostId: String? = nil) { + self.providerName = providerName + self.providerItemId = providerItemId + self.positionTicks = positionTicks + self.percentCompleted = percentCompleted + self.hostId = hostId + super.init() + } + + override func main() { + guard !isCancelled else { + self.finish() + return + } + + Task { [weak self] in + // weak: a cancelled op shouldn't be kept alive (with its queue slot) for the + // full debounce sleep just because this Task captured it + guard let self else { return } + // 1. defer guarantees this runs at the very end of the Task's scope, + // even if a crash or a throw happens inside. + defer { + self.finish() + } + try await Task.sleep(for: .seconds(5)) + // cancelAllOperations() can't reach this unstructured Task, so honor cancellation + // manually after the debounce window — defer still finishes the operation + guard !self.isCancelled else { return } + // 2. Wrap the throwing code in a do-catch + do { + + switch ExternalResource.ProviderName(rawValue: self.providerName)?.mediaServer { + case .jellyfin: + try await doJellyfinUpdate() + case .audiobookshelf: + try await doAudiobookshelfUpdate() + case nil: + // Hardcover, or a provider this build doesn't know: nothing to push to. The task is + // consumed rather than retried forever, same as it always was. + break + } + + // If the code reaches here, the network request succeeded! + self.didSucceed = true + + } catch { + if (error as? IntegrationError)?.isSessionExpired == true { + // Revoked/expired media-server token: retrying can never succeed until the user + // re-authenticates in Settings, and a failed task re-enqueues forever — consume + // the push instead of hot-looping the provider queue in the background. + Self.logger.error("External progress push dropped (session expired): \(error)") + self.didSucceed = true + } else if Self.isPermanentRejection(error) { + // HTTP 4xx from the provider (item deleted server-side, malformed id) is just as + // permanent as an unknown host or an expired session — the queue retries failures + // forever, so consume instead of spinning a network+battery cycle every 5s. + Self.logger.error("External progress push dropped (permanent rejection): \(error)") + self.didSucceed = true + } else { + Self.logger.error("External progress push failed: \(error)") + self.didSucceed = false + } + } + } + } + + /// ABS surfaces non-2xx as `IntegrationError.unexpectedResponse`; Jellyfin's SDK + /// surfaces them as `Get.APIError.unacceptableStatusCode` (401/403 are already mapped + /// to `sessionExpired` upstream when a connection exists). + private static func isPermanentRejection(_ error: Error) -> Bool { + switch error { + case IntegrationError.unexpectedResponse(let code?) where (400...499).contains(code): + return true + case APIError.unacceptableStatusCode(let status) where (400...499).contains(status): + return true + default: + return false + } + } + + func doJellyfinUpdate() async throws { + let jellyfinService = JellyfinConnectionService() + await jellyfinService.setup() + + // Stable-host resolution only. An unresolvable host means this device doesn't have the + // server the resource belongs to — pushing to whichever connection is active would write + // progress to the WRONG server, so the update is deliberately dropped. Dropping means + // CONSUMING the task (didSucceed = true): the queue only pops successful tasks and retries + // failures forever, and an unknown host is a permanent condition on this device — leaving + // it failed would retry every few seconds until the end of time (same discard semantics as + // the Android app's external-update task). + guard let targetConnection = await IntegrationHostResolver.connection( + for: self.hostId, in: jellyfinService.connections + ) else { + self.didSucceed = true + return + } + await jellyfinService.useConnection(targetConnection) + + try await jellyfinService.updateItemProgress( + self.providerItemId, + positionTicks: self.positionTicks, + percentCompleted: self.percentCompleted + ) + } + + func doAudiobookshelfUpdate() async throws { + let audiobookshelfService = AudiobookShelfConnectionService() + await audiobookshelfService.setup() + + // Same stable-host discard-by-consuming semantics as the Jellyfin path above. + guard let targetConnection = await IntegrationHostResolver.connection( + for: self.hostId, in: audiobookshelfService.connections + ) else { + self.didSucceed = true + return + } + await audiobookshelfService.useConnection(targetConnection) + + // percentCompleted arrives as 0-100 (LibraryService rounds (currentTime/duration)*100); + // ABS's progress field is a 0-1 FRACTION — pushing the raw value marks books complete. + try await audiobookshelfService.updateProgress(for: self.providerItemId, progress: self.percentCompleted / 100, currentTime: Double(self.positionTicks) / 10_000_000) + } +} diff --git a/Shared/Services/SyncQueue/FileUploadOperation.swift b/Shared/Services/SyncQueue/FileUploadOperation.swift new file mode 100644 index 000000000..04ac5404c --- /dev/null +++ b/Shared/Services/SyncQueue/FileUploadOperation.swift @@ -0,0 +1,705 @@ +// +// FileUploadOperation.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 26/3/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Combine +import Foundation + +/// A book the upload lane refuses before talking to the server +public enum UploadFileError: Error, Equatable { + /// Over the server's 10 GiB book ceiling: the file stays on this device + case fileTooLarge + + public var code: String { + switch self { + case .fileTooLarge: + return "file_too_large" + } + } + + public var message: String { + switch self { + case .fileTooLarge: + return "upload_file_too_large_message".localized + } + } +} + +/// Part math for one upload: every part but the last is exactly `partSize` +struct MultipartUploadPlan: Equatable { + let fileSize: Int64 + let partSize: Int + + var partCount: Int { + guard fileSize > 0 else { return 1 } + return Int((fileSize + Int64(partSize) - 1) / Int64(partSize)) + } + + /// Byte range of a 1-based part + func range(of partNumber: Int) -> Range { + let start = Int64(partNumber - 1) * Int64(partSize) + return start.. Int64 { + let range = range(of: partNumber) + return range.upperBound - range.lowerBound + } + + /// Parts still to send, oldest first: the session favours the newest tasks, so the + /// engine hands them over in order to keep the oldest from waiting longest + func pendingParts(done: Set, active: Set) -> [Int] { + (1...partCount).filter { !done.contains($0) && !active.contains($0) } + } + + /// How many more parts to slice now: the window, less what's in flight, and never more + /// than the free disk space (beyond `reserve`) holds + func openSlots(window: Int, active: Int, freeBytes: Int64, reserve: Int64) -> Int { + let byDisk = max(0, (freeBytes - reserve) / Int64(partSize)) + return max(0, min(window - active, Int(min(byDisk, Int64(window))))) + } +} + +/// Uploads one book to S3 as a multipart upload (bookplayer-api docs/multipart-uploads.md). +/// +/// S3 is the source of truth: every start of the operation rebuilds from S3's part list and +/// the parts the background session is still sending, so a relaunch, a missed event or an +/// app that iOS terminated mid-upload all resume without re-sending finished parts. The +/// server sets `synced` at `complete`; this operation never confirms it. +class FileUploadOperation: AsyncOperation, BPLogger, @unchecked Sendable { + static let partSize = 64 * 1024 * 1024 + /// About the per-host connection limit: more parts in flight only age their URLs (P2b) + static let window = 8 + static let maxFileSize: Int64 = 10 * 1024 * 1024 * 1024 + /// Fresh starts after S3 lost or rejected the upload, before the task parks + static let restartBudget = 3 + /// Consecutive failures of one part before the whole operation is retried + static let maxPartAttempts = 5 + /// Free space kept beyond the parts being sliced + static let diskReserve: Int64 = 256 * 1024 * 1024 + /// With no event for this long, re-read S3 and the session: an event can be lost (e.g. + /// delivered before this operation subscribed, after a relaunch) + static let reconcileInterval: Duration = .seconds(30) + + let taskId: String + let uuid: String + /// The schedule-time temp hard link (or the Processed-folder file it fell back to) + let fileURL: URL + private let provider: NetworkProvider + private let repository: SyncQueueRepositoryProtocol + private let transport: PartUploadTransport + /// The book's current file in the Processed folder, looked up by uuid: iOS may purge the + /// temp link, and the user may have moved the book since + private let libraryFileURL: (String) async -> URL? + /// Asked of the server at `start` (it answers the size it keeps); injectable for tests + private let requestedPartSize: Int + /// Free space on the volume parts are sliced to; injectable for tests + private let freeDiskSpace: () -> Int64 + private var uploadState: MultipartUploadState + + private let lock = NSLock() + private var _error: Error? + /// Why the upload stopped; nil after a success or a consumed (dropped) task + var error: Error? { + get { lock.withLock { _error } } + set { lock.withLock { _error = newValue } } + } + private var _uploadCompleted = false + /// True only when S3 assembled the file (or already had it) + private(set) var uploadCompleted: Bool { + get { lock.withLock { _uploadCompleted } } + set { lock.withLock { _uploadCompleted = newValue } } + } + private var runTask: Task? + /// Callers of `settle()` waiting for the engine to finish its current top-up. Guarded by + /// `lock`, with the engine's event stream and whether the operation is done. + private var settleWaiters = [(sequence: Int, waiter: CheckedContinuation)]() + private var settleSequence = 0 + private var eventContinuation: AsyncStream.Continuation? + private var isDone = false + + init( + taskId: String, + uuid: String, + fileURL: URL, + state: MultipartUploadState, + client: NetworkClientProtocol, + repository: SyncQueueRepositoryProtocol, + transport: PartUploadTransport = BackgroundPartUploadTransport.shared, + partSize: Int = FileUploadOperation.partSize, + freeDiskSpace: @escaping () -> Int64 = FileUploadOperation.availableDiskSpace, + libraryFileURL: @escaping (String) async -> URL? + ) { + self.requestedPartSize = partSize + self.freeDiskSpace = freeDiskSpace + self.taskId = taskId + self.uuid = uuid + self.fileURL = fileURL + self.uploadState = state + self.provider = NetworkProvider(client: client) + self.repository = repository + self.transport = transport + self.libraryFileURL = libraryFileURL + super.init() + } + + /// Parts are sliced here while they upload: `tmp/uploads//.part` (background + /// sessions only upload from files) + static func partsDirectory(for uuid: String) -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("uploads", isDirectory: true) + .appendingPathComponent(uuid, isDirectory: true) + } + + // MARK: - Execution + + override func main() { + guard !isCancelled else { + finish() + return + } + + let task = Task { [weak self] in + guard let self else { return } + await self.run() + } + lock.withLock { runTask = task } + // super.cancel() flags isCancelled before cancel() reads runTask: one of the two sees + // the other, so a cancel racing the start still stops the run + if isCancelled { task.cancel() } + } + + /// Logout, lapse or a downgrade to a tier without S3. The S3 upload is left for the + /// bucket's 7-day rule; the run stops its parts and removes its files once its loop has + /// actually stopped (cleaning up here could race a part it's about to start). + override func cancel() { + super.cancel() + let task = lock.withLock { runTask } + task?.cancel() + if error == nil { + error = BookPlayerError.cancelledTask + } + if task == nil { + // Never started: no parts, only the link + removeLocalFiles() + } + if isExecuting { finish() } + } + + private func run() async { + do { + try await upload() + uploadCompleted = true + removeLocalFiles() + } catch UploadStop.dropped { + removeLocalFiles() + } catch is CancellationError { + // The task was cleared under the upload (logout, lapse), or the operation cancelled + if error == nil { error = BookPlayerError.cancelledTask } + } catch { + if !isCancelled { + self.error = error + if SyncFailurePolicy.codedFailure(error) != nil { + // Parked: nothing reads the parts until a Retry, which starts from S3's list. + // The link stays, so the Retry still finds the file. + await removeParts() + } + } + } + if isCancelled { + if error == nil { error = BookPlayerError.cancelledTask } + await removeParts() + removeLocalFiles() + } + didSucceed = error == nil + finish() + } + + override func finish() { + lock.withLock { isDone = true } + // Finished first: a waiter told "done" must see the operation as done, so the queue + // waits for the lane's next upload instead + super.finish() + resumeSettleWaiters(upTo: nil, stillRunning: false) + } + + /// Returns once the engine has handled every part event delivered so far and handed the + /// session the next parts — `true`, still uploading — or once the operation is done + /// (`false`). A background wake calls this before telling iOS the app is finished: iOS + /// suspends the app right after, and a top-up still in flight would freeze until the next + /// wake. + func settle() async -> Bool { + await withCheckedContinuation { (waiter: CheckedContinuation) in + let (done, stream, sequence) = lock.withLock { () -> (Bool, AsyncStream.Continuation?, Int) in + if isDone { return (true, nil, 0) } + settleSequence += 1 + settleWaiters.append((settleSequence, waiter)) + return (false, eventContinuation, settleSequence) + } + if done { + waiter.resume(returning: false) + return + } + // Queued behind every event already delivered: by the time the loop reaches it, those + // are handled and the window topped up. Outside the loop (a server call), the next + // pass picks it up once its first top-up is done. + stream?.yield(.settle(sequence)) + } + } + + /// Waiters up to `sequence` (all when nil): a later waiter's events may still be queued + private func resumeSettleWaiters(upTo sequence: Int?, stillRunning: Bool) { + let waiters = lock.withLock { () -> [CheckedContinuation] in + let (ready, waiting) = settleWaiters.reduce(into: ([CheckedContinuation](), [(sequence: Int, waiter: CheckedContinuation)]())) { result, entry in + if sequence.map({ entry.sequence <= $0 }) ?? true { + result.0.append(entry.waiter) + } else { + result.1.append(entry) + } + } + settleWaiters = waiting + return ready + } + waiters.forEach { $0.resume(returning: stillRunning) } + } + + private func removeParts() async { + await transport.cancelParts(for: uuid) + try? FileManager.default.removeItem(at: Self.partsDirectory(for: uuid)) + } + + private enum UploadStop: LocalizedError { + /// Nothing to upload (the file is gone): consume the task + case dropped + /// S3 lost or rejected the upload: forget it and start again. `cause` is the server's + /// answer, used as the pause reason once the restart budget runs out. + case restart(cause: Error) + /// A part keeps failing, or the disk has no room for one: fail the operation so the + /// queue retries it later, from S3's part list + case retryLater(String) + + /// What `lastSyncError` shows + var errorDescription: String? { + switch self { + case .dropped: + return "The file to upload is gone" + case .restart(let cause): + return "Restarting the upload: \(cause.localizedDescription)" + case .retryLater(let reason): + return "Upload paused, retrying later: \(reason)" + } + } + } + + // MARK: - Upload + + private func upload() async throws { + guard let sourceURL = await resolveSourceFile() else { + Self.logger.error("Upload source missing for \(self.uuid), dropping the task") + throw UploadStop.dropped + } + let fileSize = try Self.fileSize(of: sourceURL) + guard fileSize <= Self.maxFileSize else { + throw UploadFileError.fileTooLarge + } + // The file changed since the upload started (replaced on disk), or the saved state is + // unusable: the open upload's parts no longer fit + if uploadState.uploadId != nil, uploadState.fileSize != fileSize || uploadState.partSize <= 0 { + try await forgetUpload(countingRestart: false) + } + + var partsMissingAnswers = 0 + while true { + try Task.checkCancellation() + do { + if uploadState.uploadId == nil { + guard try await start(fileSize: fileSize) else { return } + partsMissingAnswers = 0 + } + try await sendAllParts(from: sourceURL) + if try await complete() { return } + // S3's list says every part is there, yet complete says some aren't: past a few + // rounds, the upload itself is broken + partsMissingAnswers += 1 + if partsMissingAnswers >= Self.maxPartAttempts { + throw UploadStop.restart(cause: Self.partsMissingError) + } + } catch UploadStop.restart(let cause) { + guard uploadState.restartCount < Self.restartBudget else { + // Parks. The dead upload is forgotten and the budget reset, so a Retry (or the + // launch retry) starts a fresh upload instead of re-parking on the same one. + try await forgetUpload(countingRestart: false) + uploadState.restartCount = 0 + try await saveState() + throw cause + } + try await forgetUpload(countingRestart: true) + } + } + } + + /// `false` when S3 already has the file (nothing to send) + private func start(fileSize: Int64) async throws -> Bool { + let response: StartUploadResponse = try await provider.request( + .startUpload(uuid: uuid, fileSize: fileSize, partSize: requestedPartSize) + ) + switch response.status { + case .exists: + return false + case .started: + let partSize = response.partSize ?? requestedPartSize + guard let uploadId = response.uploadId, partSize > 0 else { + throw BookPlayerError.runtimeError("Upload started without an uploadId or a usable part size") + } + uploadState.uploadId = uploadId + uploadState.partSize = partSize + uploadState.fileSize = fileSize + try await saveState() + return true + } + } + + /// Keeps up to `window` parts with the session until S3 holds every part + private func sendAllParts(from sourceURL: URL) async throws { + guard let uploadId = uploadState.uploadId else { return } + let plan = MultipartUploadPlan(fileSize: uploadState.fileSize, partSize: uploadState.partSize) + + let (events, continuation) = AsyncStream.makeStream() + // Subscribed before reading the session, so no event falls between the two + let subscription = transport.subscribe(uuid: uuid, uploadId: uploadId) { continuation.yield(.part($0)) } + let ticker = Task { + while !Task.isCancelled { + try? await Task.sleep(for: Self.reconcileInterval) + continuation.yield(.tick) + } + } + // Parts already handed to the Wi-Fi-only (or the cellular) session stay there: on a + // change, cancel them so the next top-up resends them through the session that now applies + let cellularObserver = UserDefaults.standard.observe(\.userSettingsAllowCellularData, options: [.new]) { _, _ in + continuation.yield(.cellularSettingChanged) + } + let pendingSettle = lock.withLock { () -> Int? in + eventContinuation = continuation + return settleWaiters.map(\.sequence).max() + } + if let pendingSettle { + continuation.yield(.settle(pendingSettle)) + } + defer { + lock.withLock { eventContinuation = nil } + subscription.cancel() + ticker.cancel() + cellularObserver.invalidate() + continuation.finish() + } + // One consumer for the whole pass + var iterator = events.makeAsyncIterator() + + var done = try await uploadedParts(uploadId: uploadId) + var active = await transport.activePartNumbers(for: uuid, uploadId: uploadId).subtracting(done) + var bytesInFlight = [Int: Int64]() + var failures = [Int: Int]() + var expiredURLs = [Int: Int]() + var sawEventSinceTick = false + var lastReportedPercent = -1 + + while done.count < plan.partCount { + try Task.checkCancellation() + + let slots = plan.openSlots( + window: Self.window, + active: active.count, + freeBytes: freeDiskSpace(), + reserve: Self.diskReserve + ) + let next = Array(plan.pendingParts(done: done, active: active).prefix(slots)) + if !next.isEmpty { + // Fresh URLs every time: an old one may have died with its signing credentials + for part in try await partURLs(uploadId: uploadId, partNumbers: next) { + // Checked per part: a cancel must not start what nothing will clean up + try Task.checkCancellation() + let partFile = try slice(part: part.partNumber, of: sourceURL, plan: plan) + transport.startPart(uuid: uuid, uploadId: uploadId, partNumber: part.partNumber, file: partFile, url: part.url) + active.insert(part.partNumber) + } + } else if active.isEmpty { + throw UploadStop.retryLater("no disk space for the next part") + } + + guard let event = await iterator.next() else { throw CancellationError() } + switch event { + case .tick: + guard !sawEventSinceTick else { + sawEventSinceTick = false + continue + } + done = try await uploadedParts(uploadId: uploadId) + active = await transport.activePartNumbers(for: uuid, uploadId: uploadId).subtracting(done) + case .cellularSettingChanged: + // Their cancellations come back as `.resend` + await transport.cancelParts(for: uuid) + case .settle(let sequence): + // Every earlier event is handled and the window was topped up at the start of + // this pass + resumeSettleWaiters(upTo: sequence, stillRunning: true) + case .part(.progress(let partNumber, let bytesSent)): + sawEventSinceTick = true + // A part this pass's session read (at its start or a tick) no longer held: its + // task had already completed, with progress still queued + guard active.contains(partNumber) else { continue } + bytesInFlight[partNumber] = bytesSent + case .part(.finished(let partNumber, let statusCode, let error)): + sawEventSinceTick = true + guard active.remove(partNumber) != nil else { continue } + bytesInFlight[partNumber] = nil + switch Self.outcome(statusCode: statusCode, error: error) { + case .uploaded: + done.insert(partNumber) + failures[partNumber] = nil + expiredURLs[partNumber] = nil + try? FileManager.default.removeItem(at: partFileURL(partNumber)) + case .resend: + // Sent again at the next top-up with a fresh URL. Only a 403 counts: a URL that + // keeps failing when fresh isn't expiring, the request itself is refused. + if statusCode == 403 { + expiredURLs[partNumber, default: 0] += 1 + if expiredURLs[partNumber, default: 0] >= Self.maxPartAttempts { + throw UploadStop.retryLater("part \(partNumber) keeps being refused") + } + } + case .uploadGone: + throw UploadStop.restart(cause: Self.uploadLostError) + case .failed: + failures[partNumber, default: 0] += 1 + if failures[partNumber, default: 0] >= Self.maxPartAttempts { + throw UploadStop.retryLater("part \(partNumber) keeps failing") + } + } + } + lastReportedPercent = reportProgress( + plan: plan, + done: done, + bytesInFlight: bytesInFlight, + lastReportedPercent: lastReportedPercent + ) + } + } + + /// `true` once S3 assembled the file; `false` when parts turned out missing (send them) + private func complete() async throws -> Bool { + guard let uploadId = uploadState.uploadId else { return false } + let plan = MultipartUploadPlan(fileSize: uploadState.fileSize, partSize: uploadState.partSize) + do { + let _: CompleteUploadResponse = try await provider.request( + .completeUpload(uuid: uuid, uploadId: uploadId, partCount: plan.partCount, fileSize: uploadState.fileSize) + ) + return true + } catch let error as BookPlayerError { + switch Self.code(of: error) { + case "parts_missing": + return false + case "upload_not_found", "invalid_parts": + throw UploadStop.restart(cause: error) + default: + throw error + } + } + } + + private func uploadedParts(uploadId: String) async throws -> Set { + do { + let response: UploadedPartsResponse = try await provider.request( + .uploadedParts(uuid: uuid, uploadId: uploadId) + ) + return Set(response.parts.map(\.partNumber)) + } catch let error as BookPlayerError where Self.code(of: error) == "upload_not_found" { + throw UploadStop.restart(cause: error) + } + } + + private func partURLs(uploadId: String, partNumbers: [Int]) async throws -> [UploadPartURLsResponse.Part] { + do { + let response: UploadPartURLsResponse = try await provider.request( + .uploadPartURLs(uuid: uuid, uploadId: uploadId, partNumbers: partNumbers) + ) + return response.parts + } catch let error as BookPlayerError where Self.code(of: error) == "upload_not_found" { + throw UploadStop.restart(cause: error) + } + } + + /// Drops the open upload (the next pass starts a new one). Parts still in the session + /// belong to it, so they're cancelled with their files. + private func forgetUpload(countingRestart: Bool) async throws { + await transport.cancelParts(for: uuid) + try? FileManager.default.removeItem(at: Self.partsDirectory(for: uuid)) + uploadState.uploadId = nil + if countingRestart { + uploadState.restartCount += 1 + } + try await saveState() + } + + private func saveState() async throws { + guard await repository.saveUploadState(uploadState, forTask: taskId) else { + // Cleared by a logout or lapse while uploading + throw CancellationError() + } + } + + // MARK: - Events + + private enum EngineEvent { + case part(PartUploadEvent) + case tick + case cellularSettingChanged + /// A background wake waits for the current top-up (`settle()`); numbered so a wake + /// isn't released by an earlier one's marker + case settle(Int) + } + + enum PartOutcome: Equatable { + case uploaded + /// Send the part again (new URL); not counted as a failure + case resend + /// S3 no longer has the upload (NoSuchUpload) + case uploadGone + /// Retry the part, counted toward `maxPartAttempts` + case failed + } + + /// S3's answer to a part PUT (bookplayer-api docs/multipart-uploads.md): 403 = the URL + /// expired, 404 = the upload is gone, anything else = retry the part + static func outcome(statusCode: Int?, error: Error?) -> PartOutcome { + if let error = error as? URLError, error.code == .cancelled { + return .resend + } + guard error == nil, let statusCode else { return .failed } + switch statusCode { + case 200...299: + return .uploaded + case 403: + return .resend + case 404: + return .uploadGone + default: + return .failed + } + } + + /// `complete` keeps finding gaps the part list doesn't show + static let partsMissingError = BookPlayerError.networkErrorWithCode( + message: "The upload keeps missing parts", + code: "parts_missing", + status: 409 + ) + + /// A part's 404: the upload is gone, same as the server's `upload_not_found` + static let uploadLostError = BookPlayerError.networkErrorWithCode( + message: "The upload is no longer open", + code: "upload_not_found", + status: 404 + ) + + static func code(of error: BookPlayerError) -> String? { + guard case .networkErrorWithCode(_, let code, _) = error else { return nil } + return code + } + + // MARK: - Files + + private func resolveSourceFile() async -> URL? { + if FileManager.default.fileExists(atPath: fileURL.path) { + return fileURL + } + guard let libraryURL = await libraryFileURL(uuid), + FileManager.default.fileExists(atPath: libraryURL.path) + else { return nil } + return libraryURL + } + + private func partFileURL(_ partNumber: Int) -> URL { + Self.partsDirectory(for: uuid).appendingPathComponent("\(partNumber).part") + } + + /// Copies one part's bytes into its own file; reuses a complete one from an earlier pass + private func slice(part partNumber: Int, of sourceURL: URL, plan: MultipartUploadPlan) throws -> URL { + let partURL = partFileURL(partNumber) + let size = plan.size(of: partNumber) + if let existing = try? Self.fileSize(of: partURL), existing == size { + return partURL + } + + try FileManager.default.createDirectory( + at: Self.partsDirectory(for: uuid), + withIntermediateDirectories: true + ) + FileManager.default.createFile(atPath: partURL.path, contents: nil) + let reader = try FileHandle(forReadingFrom: sourceURL) + let writer = try FileHandle(forWritingTo: partURL) + defer { + try? reader.close() + try? writer.close() + } + + try reader.seek(toOffset: UInt64(plan.range(of: partNumber).lowerBound)) + var remaining = size + let chunk: Int64 = 4 * 1024 * 1024 + while remaining > 0 { + // Drained per chunk: a part is 64 MiB, and several are sliced back to back + try autoreleasepool { + guard let data = try reader.read(upToCount: Int(min(chunk, remaining))), !data.isEmpty else { + throw BookPlayerError.runtimeError("The file ended before part \(partNumber)") + } + try writer.write(contentsOf: data) + remaining -= Int64(data.count) + } + } + return partURL + } + + /// Parts, then the temp hard link (only ever a regular file inside tmp) + private func removeLocalFiles() { + try? FileManager.default.removeItem(at: Self.partsDirectory(for: uuid)) + SyncJobScheduler.removeHardLink(at: fileURL) + } + + static func fileSize(of url: URL) throws -> Int64 { + let values = try url.resourceValues(forKeys: [.fileSizeKey]) + return Int64(values.fileSize ?? 0) + } + + static func availableDiskSpace() -> Int64 { + let url = FileManager.default.temporaryDirectory +#if os(watchOS) + let values = try? url.resourceValues(forKeys: [.volumeAvailableCapacityKey]) + return Int64(values?.volumeAvailableCapacity ?? 0) +#else + let values = try? url.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + return values?.volumeAvailableCapacityForImportantUsage ?? 0 +#endif + } + + // MARK: - Progress + + /// Reports only when the whole percentage changes: eight parts call back many times a + /// second, and every report posts to the main thread. Returns the percentage reported. + private func reportProgress( + plan: MultipartUploadPlan, + done: Set, + bytesInFlight: [Int: Int64], + lastReportedPercent: Int + ) -> Int { + guard plan.fileSize > 0 else { return lastReportedPercent } + let sent = done.reduce(Int64(0)) { $0 + plan.size(of: $1) } + bytesInFlight.values.reduce(0, +) + let progress = min(1, Double(sent) / Double(plan.fileSize)) + let percent = Int(progress * 100) + guard percent != lastReportedPercent else { return lastReportedPercent } + onProgress?(progress) + return percent + } +} diff --git a/Shared/Services/SyncQueue/PartUploadTransport.swift b/Shared/Services/SyncQueue/PartUploadTransport.swift new file mode 100644 index 000000000..3b19c33a0 --- /dev/null +++ b/Shared/Services/SyncQueue/PartUploadTransport.swift @@ -0,0 +1,118 @@ +// +// PartUploadTransport.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Combine +import Foundation + +/// What happened to one part of a multipart upload +enum PartUploadEvent { + case progress(partNumber: Int, bytesSent: Int64) + /// `statusCode` is S3's answer; nil when the request never got one (`error` says why) + case finished(partNumber: Int, statusCode: Int?, error: Error?) +} + +/// Sends a multipart upload's parts. The background implementation hands them to iOS, so +/// they keep going while the app is suspended; the engine only needs this seam. +protocol PartUploadTransport: AnyObject { + /// Parts of this S3 upload the system is still sending (or holding until it can) + func activePartNumbers(for uuid: String, uploadId: String) async -> Set + /// PUTs `file` to the part's presigned URL + func startPart(uuid: String, uploadId: String, partNumber: Int, file: URL, url: URL) + /// Every part of the book, whichever upload it belongs to + func cancelParts(for uuid: String) async + /// Delivers this S3 upload's part events until the returned subscription is cancelled. + /// Events of the book's earlier uploads (cancelled by a restart) never arrive here. + func subscribe(uuid: String, uploadId: String, handler: @escaping (PartUploadEvent) -> Void) -> AnyCancellable +} + +/// `BPURLSession`'s two background sessions. Each part is its own upload task, described +/// `#@` — the only link between a task and its upload, what +/// lets a relaunch find the parts still in flight, and what keeps a restarted upload from +/// reading its predecessor's late events. +final class BackgroundPartUploadTransport: PartUploadTransport { + static let shared = BackgroundPartUploadTransport() + + private var sessions: [URLSession] { + [BPURLSession.shared.backgroundSession, BPURLSession.shared.backgroundCellularSession] + } + + static func taskDescription(uuid: String, partNumber: Int, uploadId: String) -> String { + "\(uuid)#\(partNumber)@\(uploadId)" + } + + /// nil for anything that isn't a part (e.g. a single-PUT upload from an older build). + /// Split at the first `#` and the first `@` after it: uuids carry neither. + static func parse(_ description: String?) -> (uuid: String, partNumber: Int, uploadId: String)? { + guard + let description, + let hash = description.firstIndex(of: "#"), + let at = description[hash...].firstIndex(of: "@"), + let partNumber = Int(description[description.index(after: hash).. Set { + var parts = Set() + for session in sessions { + for task in await session.allTasks where task.state == .running || task.state == .suspended { + if let parsed = Self.parse(task.taskDescription), parsed.uuid == uuid, parsed.uploadId == uploadId { + parts.insert(parsed.partNumber) + } + } + } + return parts + } + + func startPart(uuid: String, uploadId: String, partNumber: Int, file: URL, url: URL) { + let allowCellular = UserDefaults.standard.bool(forKey: Constants.UserDefaults.allowCellularData) + let session = allowCellular + ? BPURLSession.shared.backgroundCellularSession + : BPURLSession.shared.backgroundSession + // No auth header and no extra headers: the presigned signature covers `host` only + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.cachePolicy = .reloadIgnoringLocalCacheData + let task = session.uploadTask(with: request, fromFile: file) + task.taskDescription = Self.taskDescription(uuid: uuid, partNumber: partNumber, uploadId: uploadId) + task.resume() + } + + func cancelParts(for uuid: String) async { + for session in sessions { + for task in await session.allTasks { + if let parsed = Self.parse(task.taskDescription), parsed.uuid == uuid { + task.cancel() + } + } + } + } + + func subscribe(uuid: String, uploadId: String, handler: @escaping (PartUploadEvent) -> Void) -> AnyCancellable { + let belongs: (URLSessionTask) -> Int? = { task in + guard let parsed = Self.parse(task.taskDescription), + parsed.uuid == uuid, + parsed.uploadId == uploadId + else { return nil } + return parsed.partNumber + } + let progress = BPURLSession.shared.progressPublisher + .compactMap { task, bytesSent -> PartUploadEvent? in + guard let partNumber = belongs(task) else { return nil } + return .progress(partNumber: partNumber, bytesSent: bytesSent) + } + let completion = BPURLSession.shared.completionPublisher + .compactMap { task, error -> PartUploadEvent? in + guard let partNumber = belongs(task) else { return nil } + // The background delegate doesn't surface HTTP failures as errors: S3's status + // comes from the response + let status = (task.response as? HTTPURLResponse)?.statusCode + return .finished(partNumber: partNumber, statusCode: status, error: error) + } + return progress.merge(with: completion).sink(receiveValue: handler) + } +} diff --git a/Shared/Services/SyncQueue/QueueCounts.swift b/Shared/Services/SyncQueue/QueueCounts.swift new file mode 100644 index 000000000..41fb60aff --- /dev/null +++ b/Shared/Services/SyncQueue/QueueCounts.swift @@ -0,0 +1,58 @@ +// +// QueueCounts.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Combine +import Foundation + +/// Pending-task count per queue key, published by the engine that owns every lane. +/// A drained lane is simply absent from the snapshot; `count(in:)` reads it as zero. +public struct QueueCounts: Equatable { + /// Every queued task per lane, parked ones included + public let byQueueKey: [String: Int] + /// Parked tasks per lane (a subset of `byQueueKey`) + public let pausedByQueueKey: [String: Int] + /// Lanes that can't make progress until a parked task is resumed: a lane-level pause + /// at their head, or an account-level pause holding every server lane + public let blockedQueueKeys: Set + + public init( + byQueueKey: [String: Int] = [:], + pausedByQueueKey: [String: Int] = [:], + blockedQueueKeys: Set = [] + ) { + self.byQueueKey = byQueueKey + self.pausedByQueueKey = pausedByQueueKey + self.blockedQueueKeys = blockedQueueKeys + } + + /// Every queued task across all lanes + public var total: Int { byQueueKey.values.reduce(0, +) } + + /// Every parked task across all lanes + public var totalPaused: Int { pausedByQueueKey.values.reduce(0, +) } + + public func count(in queueKey: String) -> Int { byQueueKey[queueKey] ?? 0 } + + public func pausedCount(in queueKey: String) -> Int { pausedByQueueKey[queueKey] ?? 0 } + + public func isBlocked(_ queueKey: String) -> Bool { blockedQueueKeys.contains(queueKey) } + + /// Nothing in the lane can run: it's empty, holds only parked tasks, or is blocked + public func isIdle(_ queueKey: String) -> Bool { + isBlocked(queueKey) || count(in: queueKey) == pausedCount(in: queueKey) + } +} + +extension Publisher where Output == QueueCounts, Failure == Never { + /// `true` whenever the given lane has nothing left it can run (`isIdle`): a parked task + /// never finishes on its own, so waiting on it would only hold the window open. + /// Background refresh waits on the `sync` lane through this, so the policy reads the + /// same at the call site and in tests. + public func laneDrained(_ queueKey: String) -> AnyPublisher { + map { $0.isIdle(queueKey) }.eraseToAnyPublisher() + } +} diff --git a/Shared/Services/SyncQueue/QueuedSyncTask.swift b/Shared/Services/SyncQueue/QueuedSyncTask.swift new file mode 100644 index 000000000..9fab7132f --- /dev/null +++ b/Shared/Services/SyncQueue/QueuedSyncTask.swift @@ -0,0 +1,41 @@ +// +// QueuedSyncTask.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 24/3/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +public struct QueuedSyncTask: Identifiable { + public let id: String + public let queueKey: String + public let jobType: SyncJobType + public let uuid: String + public let relativePath: String + public let parameters: [String: Any] + /// Set while the task is parked on a coded server error + public let pause: TaskPause? + + public init( + id: String, + queueKey: String, + jobType: SyncJobType, + parameters: [String: Any], + uuid: String = "", + relativePath: String = "", + pause: TaskPause? = nil + ) { + self.id = id + self.queueKey = queueKey + self.jobType = jobType + self.parameters = parameters + self.uuid = uuid + self.relativePath = relativePath + self.pause = pause + } + + /// The key `.uploadProgressUpdated` carries for this item — what a row matches on + public var progressKey: String { + SyncProgressKey.resolve(uuid: uuid, relativePath: relativePath) + } +} diff --git a/Shared/Services/SyncQueue/SyncQueueProgressMonitor.swift b/Shared/Services/SyncQueue/SyncQueueProgressMonitor.swift new file mode 100644 index 000000000..1e8cd51b3 --- /dev/null +++ b/Shared/Services/SyncQueue/SyncQueueProgressMonitor.swift @@ -0,0 +1,73 @@ +// +// SyncQueueProgressMonitor.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 25/3/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation +import SwiftUI +import SwiftData + +public enum ActiveTaskState: Equatable { + case processing // Indeterminate (API calls, updates) + case progress(Double) // Determinate (Files, byte progress) +} + +@Observable +@MainActor +public class TaskProgressTracker { + var state: ActiveTaskState + + init(state: ActiveTaskState) { + self.state = state + } +} + +@Observable +@MainActor +public class SyncQueueProgressMonitor { + // Shared instance so both the UI and the Orchestrator can access it easily + public static let shared = SyncQueueProgressMonitor() + + // A dictionary mapping the Task ID to a progress double (0.0 to 1.0) + public var activeTasks: [String: TaskProgressTracker] = [:] + + func markAsProcessing(taskID: String) { + if let tracker = activeTasks[taskID] { + tracker.state = .processing + } else { + activeTasks[taskID] = TaskProgressTracker(state: .processing) + } + } + + func updateProgress(for taskID: String, progress: Double) { + // We no longer mutate the dictionary! We just update the object's property. + // This surgical update is what SwiftUI's observation engine craves. + activeTasks[taskID]?.state = .progress(progress) + } + + func clear(taskID: String) { + activeTasks.removeValue(forKey: taskID) + } + + public func isActive(taskID: String) -> Bool { + return activeTasks.keys.contains(taskID) + } + + public func isTaskState(taskID: String) -> ActiveTaskState? { + return activeTasks[taskID]?.state ?? nil + } + + public func getTaskProgress(taskID: String) -> Double { + guard let myState = activeTasks[taskID]?.state else { return 0 } + + switch myState { + case .processing: + return 0 + case let .progress(progress): + return progress + } + } +} diff --git a/Shared/Services/SyncQueue/SyncQueueRepository.swift b/Shared/Services/SyncQueue/SyncQueueRepository.swift new file mode 100644 index 000000000..835bcd7f1 --- /dev/null +++ b/Shared/Services/SyncQueue/SyncQueueRepository.swift @@ -0,0 +1,787 @@ +// +// SyncQueueRepository.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 23/3/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Combine +import Foundation +import SwiftData + +public protocol SyncQueueRepositoryProtocol: ModelActor { + init(tasksDataManager: TasksDataManager) + + /// The next task the lane's worker should run, marked in flight (never a coalescing + /// target until popped or parked). Worker-only: use `hasRunnableTask(for:)` to peek. + func getNextTask(for queueKey: String) -> QueuedSyncTask? + + /// Whether the lane has a task its worker could run, without marking anything in flight + func hasRunnableTask(for queueKey: String) -> Bool + + func pop(_ task: QueuedSyncTask) + + func getAllQueueKeys() -> [String] + + func storeTask(parameters: [String: Any]) async throws + + /// Every queued task across all lanes in stored order — a display-level list, so + /// `parameters` is left empty (workers reload payloads through `getNextTask`) + func getAllTasks() async -> [QueuedSyncTask] + + // Set (not the @MainActor TaskProgressTracker map): only the ids cross the actor + // boundary — the tracker object is non-Sendable. + func getOrderedTasks(activeTaskIDs: Set) async -> [QueuedSyncTask] + + func getTasksCount(in queueKey: String) -> Int + + func getAllTasksWithParams(in queueKey: String) -> [SyncTask] + + /// The tasks that can lead to a book file upload, read in one go (the sync lane's + /// `.upload`s and media-server file jobs first, then the upload lane): only their payloads + /// are fetched, not every bookmark or move job's + func getUploadCandidates() -> [UploadCandidate] + + func hasUploadTask(for relativePath: String) -> Bool + + /// Stores each upload-lane task unless its book already has an upload queued in either + /// lane, parked ones included. Returns how many were stored. + func storeFileUploadsIfAbsent(_ parameterList: [[String: Any]]) async throws -> Int + + func applyMatchUuidConflicts(_ conflicts: [ItemConflict]) throws + + func clearAll(in queueKey: String) throws + + func clearAll() throws + + /// Parks the task. Returns the stored pause (carrying any earlier `sentryEventId`), or + /// nil when the task is gone (popped or cleared meanwhile). + @discardableResult + func park(taskId: String, pause: TaskPause) -> TaskPause? + + /// Returns a parked task to pending. Resuming one account-level pause resumes them all: + /// they share one cause. + func resume(taskId: String) + + /// Returns every parked task to pending (the one automatic retry, at launch) + func resumeAllPaused() + + /// Remembers the Sentry event that reported this task's pause + func setSentryEventId(_ eventId: String, forTask taskId: String) + + /// Persists a multipart upload's progress-independent state, so a relaunch resumes the + /// same S3 upload instead of starting over. A nil `uploadId` forgets the open upload. + /// `false` when the task is gone (cleared by a logout or lapse mid-upload): stop uploading. + @discardableResult + func saveUploadState(_ state: MultipartUploadState, forTask taskId: String) -> Bool +} + +/// What a multipart upload must remember across launches (the parts themselves are read +/// back from S3). `uploadId` alone decides resume vs start: the sizes only count while it's +/// set (0 means unset, and the server rejects 0). +public struct MultipartUploadState: Equatable, Sendable { + public var uploadId: String? + public var partSize: Int + public var fileSize: Int64 + public var restartCount: Int + + public init(uploadId: String?, partSize: Int, fileSize: Int64, restartCount: Int) { + self.uploadId = uploadId + self.partSize = partSize + self.fileSize = fileSize + self.restartCount = restartCount + } + + /// Read back from an `uploadFile` task's parameters (`UploadFileTaskModel.toDictionaryPayload`) + public init(parameters: [String: Any]) { + self.init( + uploadId: parameters["uploadId"] as? String, + partSize: parameters["partSize"] as? Int ?? 0, + fileSize: parameters["fileSize"] as? Int64 ?? 0, + restartCount: parameters["restartCount"] as? Int ?? 0 + ) + } +} + +public actor SyncQueueRepository: SyncQueueRepositoryProtocol, BPLogger { + nonisolated public let modelContainer: ModelContainer + nonisolated public let modelExecutor: any ModelExecutor + + private let tasksDataManager: TasksDataManager + /// The task each lane's worker is running: handed out by `getNextTask`, dropped on pop + /// or park. Its parameters were already read, so it must never be a coalescing target — + /// and once parking exists it isn't always the lane's first row (a Retry can put a + /// resumed task ahead of it). + private var inFlightTaskIDs: [String: String] = [:] + + public init(tasksDataManager: TasksDataManager) { + self.modelContainer = tasksDataManager.container + let modelContext = ModelContext(tasksDataManager.container) + // Every mutating method saves explicitly (pop even has a save→rollback→re-save + // retry) — autosave would make persistence timing nondeterministic + modelContext.autosaveEnabled = false + self.modelExecutor = DefaultSerialModelExecutor(modelContext: modelContext) + self.tasksDataManager = tasksDataManager + } + + public func getNextTask(for queueKey: String) -> QueuedSyncTask? { + guard let (reference, storedObject) = nextRunnableTask(for: queueKey) else { return nil } + + inFlightTaskIDs[queueKey] = reference.taskID + return QueuedSyncTask( + id: reference.taskID, + queueKey: reference.queueKey, + jobType: reference.jobType, + parameters: storedObject.toDictionaryPayload(), + uuid: reference.uuid, + relativePath: reference.relativePath, + pause: reference.pause + ) + } + + public func hasRunnableTask(for queueKey: String) -> Bool { + nextRunnableTask(for: queueKey) != nil + } + + /// The pause rules: `.task` rows are skipped, a `.lane`/`.account` head stops the lane, + /// and any `.account` pause holds every server lane + private func nextRunnableTask( + for queueKey: String + ) -> (QueuedTaskReferenceModel, any DictionaryConvertible)? { + guard let tasksContainer = fetchGlobalQueueModel() else { return nil } + + if TaskQueueKey.isServerLane(queueKey), + tasksContainer.tasks.contains(where: { $0.pauseScopeValue == .account }) { + return nil + } + + for reference in tasksContainer.orderedTasks(for: queueKey) { + switch reference.pauseScopeValue { + case .task: + continue + case .lane, .account: + return nil + case nil: + break + } + + guard + let storedObject = tasksDataManager.getTaskModel( + with: reference.taskID, + jobType: reference.jobType, + in: modelContext + ) + else { + /// Drop dangling references (payload missing) instead of stalling the queue + tasksContainer.tasks.removeAll(where: { $0.id == reference.id }) + modelContext.delete(reference) + do { + try modelContext.save() + } catch { + // Unsaved = the dangling reference resurfaces on the next getNextTask pass; + // log like every other save site so repeats are diagnosable + Self.logger.error("Failed to persist dangling-reference cleanup: \(error)") + } + tasksDataManager.notifyTasksChanged(context: modelContext) + continue + } + + return (reference, storedObject) + } + + return nil + } + + @discardableResult + public func park(taskId: String, pause: TaskPause) -> TaskPause? { + clearInFlight(taskId) + guard + let reference = fetchGlobalQueueModel()?.tasks.first(where: { $0.taskID == taskId }) + else { return nil } + + reference.pauseScope = pause.scope.rawValue + reference.errorCode = pause.errorCode + reference.errorMessage = pause.message + reference.httpStatus = pause.httpStatus + reference.pausedAt = pause.pausedAt + saveAndNotify("park \(taskId)") + return reference.pause + } + + public func resume(taskId: String) { + guard + let tasks = fetchGlobalQueueModel()?.tasks, + let reference = tasks.first(where: { $0.taskID == taskId }), + let scope = reference.pauseScopeValue + else { return } + + if scope == .account { + tasks.filter { $0.pauseScopeValue == .account }.forEach { $0.clearPause() } + } else { + reference.clearPause() + } + saveAndNotify("resume \(taskId)") + } + + public func resumeAllPaused() { + let paused = fetchGlobalQueueModel()?.tasks.filter { $0.pauseScope != nil } ?? [] + guard !paused.isEmpty else { return } + + paused.forEach { $0.clearPause() } + saveAndNotify("resume all paused tasks") + } + + public func setSentryEventId(_ eventId: String, forTask taskId: String) { + guard + let reference = fetchGlobalQueueModel()?.tasks.first(where: { $0.taskID == taskId }) + else { return } + + reference.sentryEventId = eventId + saveAndNotify("record the report of \(taskId)") + } + + @discardableResult + public func saveUploadState(_ state: MultipartUploadState, forTask taskId: String) -> Bool { + guard + let task = try? modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == taskId }) + ).first + else { + Self.logger.info("Upload task \(taskId) is gone; its state wasn't saved") + return false + } + + task.uploadId = state.uploadId + task.partSize = state.partSize + task.fileSize = state.fileSize + task.restartCount = state.restartCount + do { + try modelContext.save() + } catch { + Self.logger.error("Failed to persist the upload state of \(taskId): \(error)") + } + return true + } + + private func clearInFlight(_ taskId: String) { + inFlightTaskIDs = inFlightTaskIDs.filter { $0.value != taskId } + } + + private func saveAndNotify(_ action: String) { + do { + try modelContext.save() + } catch { + Self.logger.error("Failed to persist \(action): \(error)") + } + tasksDataManager.notifyTasksChanged(context: modelContext) + } + + public func pop(_ task: QueuedSyncTask) { + clearInFlight(task.id) + guard let tasksContainer = fetchGlobalQueueModel() else { return } + + let context = modelContext + + try? tasksDataManager.deleteTaskModel( + with: task.id, + jobType: task.jobType, + context: context + ) + + if let reference = tasksContainer.tasks.first(where: { $0.taskID == task.id }) { + tasksContainer.tasks.removeAll(where: { $0.id == reference.id }) + context.delete(reference) + } + + // Removal happens ONLY here (getNextTask never removes): a silently-failed save leaves the + // reference behind and the worker re-runs the same task forever. Retry once after a + // rollback, then surface loudly. + do { + try context.save() + } catch { + Self.logger.error("Failed to persist task removal (\(task.id)), retrying: \(error)") + context.rollback() + // rollback() undid the payload delete above too — re-issue it or the payload + // row stays orphaned until the next clearAll() + try? tasksDataManager.deleteTaskModel( + with: task.id, + jobType: task.jobType, + context: context + ) + if let reference = tasksContainer.tasks.first(where: { $0.taskID == task.id }) { + tasksContainer.tasks.removeAll(where: { $0.id == reference.id }) + context.delete(reference) + } + do { + try context.save() + } catch { + Self.logger.error("Task removal persistently failing (\(task.id)) — the worker will re-run it: \(error)") + } + } + + tasksDataManager.notifyTasksChanged(context: context) + } + + private func fetchGlobalQueueModel() -> SyncQueueContainer? { + let context = modelContext + + let descriptor = FetchDescriptor() + let containers = try? context.fetch(descriptor) + + guard let tasksContainer = containers?.first else { + return nil + } + + return tasksContainer + } + + public func getAllQueueKeys() -> [String] { + return fetchGlobalQueueModel()?.allQueueKeys ?? [] + } + + public func storeTask(parameters: [String: Any]) async throws { + guard + let taskId = parameters["id"] as? String, + let rawJobType = parameters["jobType"] as? String, + let jobType = SyncJobType(rawValue: rawJobType), + let queueKey = parameters["queueKey"] as? String + else { + throw BookPlayerError.runtimeError("Missing id, job type or queue key when creating task") + } + + let context = modelContext + + // Get or create the tasks container + let descriptor = FetchDescriptor() + let containers = try context.fetch(descriptor) + let tasksContainer = containers.first ?? SyncQueueContainer() + + if containers.isEmpty { + context.insert(tasksContainer) + } + + if coalesceTaskIfPossible( + jobType: jobType, + queueKey: queueKey, + parameters: parameters, + tasksContainer: tasksContainer + ) { + try context.save() + tasksDataManager.notifyTasksChanged(context: context) + return + } + + tasksDataManager.createTaskModel(for: jobType, with: parameters, in: context) + + let nextPosition = (tasksContainer.tasks.map(\.position).max() ?? -1) + 1 + // Create task reference + let taskReference = QueuedTaskReferenceModel( + queueKey: queueKey, + taskID: taskId, + jobType: jobType, + position: nextPosition, + uuid: parameters["uuid"] as? String ?? "", + relativePath: parameters["relativePath"] as? String ?? "" + ) + + // Add to container + tasksContainer.tasks.append(taskReference) + taskReference.container = tasksContainer + + try context.save() + tasksDataManager.notifyTasksChanged(context: context) + + NotificationCenter.default.post( + name: .newTaskInQueue, + object: nil, + userInfo: ["queueKey": queueKey] + ) + } + + /// Merge the new task into an equivalent queued one when possible, so the queue + /// doesn't accumulate redundant work. The running task is never a merge target: its + /// parameter snapshot was already read by `getNextTask`, so mutations would be silently + /// dropped when it finishes and gets deleted. + private func coalesceTaskIfPossible( + jobType: SyncJobType, + queueKey: String, + parameters: [String: Any], + tasksContainer: SyncQueueContainer + ) -> Bool { + /// Parked tasks are never merge targets (new work would wait behind their failure), + /// nor is the running one: its parameters were already read. It used to be the lane's + /// first row, but a Retry can now put a resumed task ahead of it, so it's excluded by id. + let mergeableReferences = tasksContainer.orderedTasks(for: queueKey).filter { + $0.pauseScope == nil && $0.taskID != inFlightTaskIDs[queueKey] + } + + switch jobType { + case .matchUuid: + guard + let newUuidsDict = parameters["uuids"] as? [String: String], + let candidateReference = mergeableReferences.last(where: { $0.jobType == .matchUuid }), + let candidateTask = try? modelContext + .fetch(FetchDescriptor()) + .first(where: { $0.id == candidateReference.taskID }) + else { + return false + } + + /// Prefer existing values so we never overwrite uuids that other queued tasks or + /// Core Data are already referencing + var merged = candidateTask.uuids + for (path, uuid) in newUuidsDict where merged[path] == nil { + merged[path] = uuid + } + /// Past the server's per-request limit the merged task could never succeed: queue a + /// separate task instead + guard merged.count <= SyncJobScheduler.matchUuidsBatchLimit else { + return false + } + candidateTask.uuids = merged + return true + + case .update: + guard + let uuid = parameters["uuid"] as? String, + let candidateReference = mergeableReferences.last(where: { $0.jobType == .update && $0.uuid == uuid }), + let candidateTask = try? modelContext + .fetch(FetchDescriptor()) + .first(where: { $0.id == candidateReference.taskID }) + else { + return false + } + + var parameters = parameters + parameters["id"] = candidateTask.id + tasksDataManager.updateTaskModel(candidateTask, with: parameters) + return true + + case .externalUpdate: + guard let providerId = parameters["providerId"] as? String else { + return false + } + // The reference doesn't carry providerId, so match through the fetched tasks: + // taking just the LAST externalUpdate reference misses whenever the queue holds + // pushes for more than one book, creating a duplicate task instead of coalescing + let externalTasks = (try? modelContext.fetch(FetchDescriptor())) ?? [] + let tasksByID = Dictionary( + uniqueKeysWithValues: externalTasks + .filter { $0.providerId == providerId } + .map { ($0.id, $0) } + ) + guard + let candidateReference = mergeableReferences.last(where: { + $0.jobType == .externalUpdate && tasksByID[$0.taskID] != nil + }), + let candidateTask = tasksByID[candidateReference.taskID] + else { + return false + } + + tasksDataManager.updateExternalUpdateTaskModel(for: candidateTask, with: parameters, in: modelContext) + return true + + default: + return false + } + } + + public func getAllTasks() async -> [QueuedSyncTask] { + guard let tasksContainer = fetchGlobalQueueModel() else { return [] } + + return tasksContainer.orderedTasks.map { task in + QueuedSyncTask( + id: task.taskID, + queueKey: task.queueKey, + jobType: task.jobType, + parameters: [:], + uuid: task.uuid, + relativePath: task.relativePath, + pause: task.pause + ) + } + } + + public func getOrderedTasks(activeTaskIDs: Set) async -> [QueuedSyncTask] { + let concurrentTasks = await self.getAllTasks() + + let activeGroup = concurrentTasks.filter { task in + activeTaskIDs.contains(task.id) + } + + // 2. Sieve out ONLY the tasks that are NOT active. + let inactiveGroup = concurrentTasks.filter { task in + !activeTaskIDs.contains(task.id) + } + + // 3. Merge them back together, active ones first! + return activeGroup + inactiveGroup + } + + public func getTasksCount(in queueKey: String) -> Int { + guard let tasksContainer = fetchGlobalQueueModel() else { return 0 } + + return tasksContainer.tasks.filter { $0.queueKey == queueKey }.count + } + + public func getUploadCandidates() -> [UploadCandidate] { + guard let tasksContainer = fetchGlobalQueueModel() else { return [] } + + let syncCandidates = tasksContainer.orderedTasks(for: TaskQueueKey.sync).filter { + $0.jobType == .upload || $0.jobType == .externalResourceToDownload + } + let uploads = tasksContainer.orderedTasks(for: TaskQueueKey.uploadFile) + // One fetch per payload type, not per task: this runs on every queue change during a + // continued run, on the actor the workers need + var payloads = [String: any DictionaryConvertible]() + do { + for model in try modelContext.fetch(FetchDescriptor()) { + payloads[model.id] = model + } + for model in try modelContext.fetch(FetchDescriptor()) { + payloads[model.id] = model + } + for model in try modelContext.fetch(FetchDescriptor()) { + payloads[model.id] = model + } + } catch { + // Read as "nothing waiting", which can end a continued run early: make it traceable + Self.logger.error("Failed to read the upload candidates: \(error)") + } + return (syncCandidates + uploads).compactMap { taskRef in + guard let storedObject = payloads[taskRef.taskID] else { return nil } + + return UploadCandidate( + task: SyncTask( + id: taskRef.taskID, + uuid: taskRef.uuid, + relativePath: taskRef.relativePath, + jobType: taskRef.jobType, + parameters: storedObject.toDictionaryPayload() + ), + queueKey: taskRef.queueKey, + isParked: taskRef.pauseScope != nil + ) + } + } + + public func getAllTasksWithParams(in queueKey: String) -> [SyncTask] { + guard let tasksContainer = fetchGlobalQueueModel() else { return [] } + + return tasksContainer.orderedTasks(for: queueKey).compactMap { taskRef in + guard + let storedObject = tasksDataManager.getTaskModel( + with: taskRef.taskID, + jobType: taskRef.jobType, + in: modelContext + ) + else { + return nil + } + + return SyncTask( + id: taskRef.taskID, + uuid: taskRef.uuid, + relativePath: taskRef.relativePath, + jobType: taskRef.jobType, + parameters: storedObject.toDictionaryPayload() + ) + } + } + + /// Checked and stored in one actor turn, so an upload queued meanwhile can't be doubled. + /// One save and one change notice for the whole batch: a LITE → PRO backlog can be + /// thousands of books, and a store per task would hold the actor for each of them + public func storeFileUploadsIfAbsent(_ parameterList: [[String: Any]]) async throws -> Int { + var queued = Set(getUploadCandidates().map(\.task.uuid)) + let context = modelContext + let containers = try context.fetch(FetchDescriptor()) + let tasksContainer = containers.first ?? SyncQueueContainer() + if containers.isEmpty { + context.insert(tasksContainer) + } + var nextPosition = (tasksContainer.tasks.map(\.position).max() ?? -1) + 1 + var stored = 0 + for parameters in parameterList { + guard + let taskId = parameters["id"] as? String, + let uuid = parameters["uuid"] as? String, + !queued.contains(uuid) + else { continue } + // Upload-lane tasks never coalesce: each is its book's one upload + tasksDataManager.createTaskModel(for: .uploadFile, with: parameters, in: context) + let taskReference = QueuedTaskReferenceModel( + queueKey: TaskQueueKey.uploadFile, + taskID: taskId, + jobType: .uploadFile, + position: nextPosition, + uuid: uuid, + relativePath: parameters["relativePath"] as? String ?? "" + ) + tasksContainer.tasks.append(taskReference) + taskReference.container = tasksContainer + nextPosition += 1 + queued.insert(uuid) + stored += 1 + } + guard stored > 0 else { return 0 } + + try context.save() + tasksDataManager.notifyTasksChanged(context: context) + NotificationCenter.default.post( + name: .newTaskInQueue, + object: nil, + userInfo: ["queueKey": TaskQueueKey.uploadFile] + ) + return stored + } + + /// Check if there's an upload task queued for the item + public func hasUploadTask(for relativePath: String) -> Bool { + do { + let descriptor = FetchDescriptor( + predicate: #Predicate { task in + task.relativePath == relativePath + } + ) + + let tasks = try modelContext.fetch(descriptor) + return !tasks.isEmpty + + } catch { + return false + } + } + + /// Rewrites task reference and task model uuids for each conflict returned by `matchUuid`. + /// Each conflict maps a locally-known uuid (`key`) to the uuid the server wants the client + /// to adopt (`uuid`). Runs on the actor's serial executor. + public func applyMatchUuidConflicts(_ conflicts: [ItemConflict]) throws { + for conflict in conflicts { + let oldUuid = conflict.key + let newUuid = conflict.uuid + let refs = try modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.uuid == oldUuid }) + ) + for ref in refs { + ref.uuid = newUuid + let taskId = ref.taskID + switch ref.jobType { + case .upload: + if let task = try modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == taskId }) + ).first { task.uuid = newUuid } + case .update: + if let task = try modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == taskId }) + ).first { task.uuid = newUuid } + case .move: + if let task = try modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == taskId }) + ).first { task.uuid = newUuid } + case .renameFolder: + if let task = try modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == taskId }) + ).first { task.uuid = newUuid } + case .delete, .shallowDelete: + if let task = try modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == taskId }) + ).first { task.uuid = newUuid } + case .setBookmark: + if let task = try modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == taskId }) + ).first { task.uuid = newUuid } + case .deleteBookmark: + if let task = try modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == taskId }) + ).first { task.uuid = newUuid } + case .uploadArtwork: + if let task = try modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == taskId }) + ).first { task.uuid = newUuid } + case .matchUuid, .externalUpdate: + break + case .externalResource: + if let task = try modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == taskId }) + ).first { task.uuid = newUuid } + case .externalResourceToDownload: + if let task = try modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == taskId }) + ).first { task.uuid = newUuid } + case .deleteExternalResource: + if let task = try modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == taskId }) + ).first { task.uuid = newUuid } + case .uploadFile: + if let task = try modelContext.fetch( + FetchDescriptor(predicate: #Predicate { $0.id == taskId }) + ).first { task.uuid = newUuid } + } + } + } + try modelContext.save() + } + + public func clearAll(in queueKey: String) throws { + inFlightTaskIDs[queueKey] = nil + guard let tasksContainer = fetchGlobalQueueModel() else { return } + + let context = modelContext + + for reference in tasksContainer.orderedTasks(for: queueKey) { + try? tasksDataManager.deleteTaskModel( + with: reference.taskID, + jobType: reference.jobType, + context: context + ) + tasksContainer.tasks.removeAll(where: { $0.id == reference.id }) + context.delete(reference) + } + + try context.save() + + tasksDataManager.notifyTasksChanged(context: context) + } + + public func clearAll() throws { + inFlightTaskIDs.removeAll() + try tasksDataManager.deleteAllTasks(with: modelContext) + } +} + +extension QueuedTaskReferenceModel { + var pauseScopeValue: TaskPauseScope? { + pauseScope.flatMap(TaskPauseScope.init(rawValue:)) + } + + var pause: TaskPause? { + guard let scope = pauseScopeValue else { return nil } + return TaskPause( + scope: scope, + errorCode: errorCode ?? "", + message: errorMessage ?? "", + httpStatus: httpStatus, + pausedAt: pausedAt ?? Date(), + sentryEventId: sentryEventId + ) + } + + /// Back to pending. `sentryEventId` stays, so a re-park isn't reported twice. + func clearPause() { + pauseScope = nil + errorCode = nil + errorMessage = nil + httpStatus = nil + pausedAt = nil + } +} + +/// A task that can lead to a book file upload +public struct UploadCandidate { + public let task: SyncTask + public let queueKey: String + public let isParked: Bool +} diff --git a/Shared/Services/SyncQueue/SyncQueueService.swift b/Shared/Services/SyncQueue/SyncQueueService.swift new file mode 100644 index 000000000..374661337 --- /dev/null +++ b/Shared/Services/SyncQueue/SyncQueueService.swift @@ -0,0 +1,967 @@ +// +// SyncQueueService.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 23/3/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation +import Combine +import CoreData + +/// A book the server holds without its file, for the upload lane +public struct MissingFileUpload: Sendable, Equatable { + public let uuid: String + public let relativePath: String + /// The book's current Processed file + public let fileURL: URL + + public init(uuid: String, relativePath: String, fileURL: URL) { + self.uuid = uuid + self.relativePath = relativePath + self.fileURL = fileURL + } +} + +public protocol SyncQueueServiceProtocol { + var accessPolicy: [SyncJobType: Bool] { get set } + + /// Shared repository backing every task queue + var taskContainer: SyncQueueRepositoryProtocol! { get } + + /// Last sync error information for debugging + var lastSyncError: SyncErrorInfo? { get } + + init(maxConcurrentTasks: Int) + + func setup( + libraryService: LibrarySyncProtocol, + getAccessLevel: @escaping () -> AccessLevel, + verifySyncEntitlement: @escaping () async -> Bool?, + tasksDataManager: TasksDataManager, + networkClient: NetworkClientProtocol, + dataManager: DataManager + ) + + /// Pending-task counts for every lane, delivered on main; replays the current snapshot + /// on subscribe. The engine owns all queues, so this is the single source for any count + /// shown in the UI — per lane via `count(in:)`, or `total`. + func observeQueueCounts() -> AnyPublisher + + /// Every queued task across all lanes, the in-flight ones first (display-level list) + func getOrderedQueuedJobs(activeTaskIDs: Set) async -> [QueuedSyncTask] + + func scheduleMetadataUpdate(params: [String: Any]) + + func scheduleFileUpload(params: [String: Any]) + + /// Queues the files of books the server holds without one (the missing-items pass), by + /// uuid, skipping any book with an upload already queued. Returns how many were queued. + func scheduleMissingFileUploads(_ uploads: [MissingFileUpload]) async -> Int + + /// Adopts the server's uuids for items it holds under another one (a `matchUuid` answer): + /// in the library, then in every queued task + func applyUuidConflicts(_ conflicts: [ItemConflict]) async throws + + /// Cancels in-flight BookPlayer-server operations (serial sync + S3 uploads) on a + /// subscription lapse, leaving the tier-independent externalUpdate operations running. + /// Logout cancels everything via the `.logout` observer instead. + func cancelServerQueueOperations() + + /// Whether the BookPlayer-server lanes (`sync`, `uploadFile`) may run. Mirrors + /// `SyncService.isActive`; starts off. + var serverLanesEnabled: Bool { get } + + /// Gates the BookPlayer-server lanes without touching their persisted tasks. Off holds + /// them (a lapsed account's tasks would otherwise be rejected by the server forever); + /// on wakes them. + func setServerLanesEnabled(_ enabled: Bool) + + /// The user's Retry on a parked task: back to pending, and its lane wakes. Returns once + /// the task is pending again. + func retryPausedTask(id: String) async + + /// The latest queue counts, read now + var queueCounts: QueueCounts { get } + + /// Remembers the Sentry event that reported a pause, so it's never reported twice + func recordPauseReport(eventId: String, forTask taskId: String) + + /// Removes an upload the app refuses (a book over the size limit): the book stays on this + /// device only. The one dismissible pause — server-refused tasks only Retry or Report. + func dismissPausedTask(id: String) + + /// Returns once the running upload has handled the part events delivered so far and + /// queued its next parts, or right away when no upload will run (sync off, nothing + /// runnable). A background wake awaits this before telling iOS it's finished. + func settleUploads() async + + /// Books whose file still has to reach S3, in queue order — the sync lane's book + /// `.upload`s (not media-server ones: they carry no file) and media-server file jobs, then + /// the upload lane — with the parked ones counted apart (they wait for the user) + func pendingBookUploads() async -> PendingBookUploads +} + +/// A book still to upload, as the continued task counts it +public struct PendingBookUpload: Equatable { + public let uuid: String + public let relativePath: String + /// On disk now (0 when the file can't be read) + public let fileSize: Int64 + /// The lane it's waiting in: a blocked lane can't bring it any closer + public let queueKey: String + + public init(uuid: String, relativePath: String, fileSize: Int64, queueKey: String = TaskQueueKey.uploadFile) { + self.uuid = uuid + self.relativePath = relativePath + self.fileSize = fileSize + self.queueKey = queueKey + } + + public var fileName: String { (relativePath as NSString).lastPathComponent } +} + +public struct PendingBookUploads: Equatable { + /// Not parked, in queue order + public let books: [PendingBookUpload] + /// Parked book uploads (either lane) + public let parkedCount: Int + + public init(books: [PendingBookUpload], parkedCount: Int) { + self.books = books + self.parkedCount = parkedCount + } +} + +public class SyncQueueService: SyncQueueServiceProtocol, BPLogger { + let operationQueue: OperationQueue + public var taskContainer: SyncQueueRepositoryProtocol! // Your DB model + var libraryService: LibrarySyncProtocol! + var networkClient: NetworkClientProtocol! + var dataManager: DataManager! + + private var _accessPolicy: [SyncJobType: Bool] = [:] + public var accessPolicy: [SyncJobType: Bool] { + get { + policyLock.withLock { + return _accessPolicy + } + } + set { + policyLock.withLock { + _accessPolicy = newValue + } + } + } + // Tracks which queueKeys currently have an active worker looping + private var activeQueueKeys = Set() + /// Off until SyncService reports the account's sync state: workers wake in `setup`, + /// before SyncService is set up, and a lapsed account's persisted tasks must never + /// reach the server. Guarded by `stateLock`, together with `activeQueueKeys`. + private var _serverLanesEnabled = false + /// Whether coded failures park (kept, visible, retryable) or are dropped. Off on the + /// watch, which has no Queued Tasks screen to show or retry them from. Set before `setup`. + public var parkingEnabled = true + /// Fresh RevenueCat read of the sync entitlement (nil = the check failed), for an + /// account-level rejection. Its update also drives the lapse path when inactive. + var verifySyncEntitlement: (() async -> Bool?)! + /// The book as it stands now, by uuid — for handing an upload the server doesn't + /// recognize back to the sync lane. Internal for @testable injection. + lazy var findSyncableItem: (String) async -> SyncableItem? = { [weak self] uuid in + await self?.libraryService?.fetchSyncableItem(forUuid: uuid) + } + /// Books handed back to the sync lane this session. A second `item_not_found` for one + /// means re-registering doesn't help (e.g. another uuid holds its key on the server): + /// it parks instead of cycling. Guarded by `stateLock`. + private var handedBackUuids = Set() + private let stateLock = NSLock() + private let policyLock = NSLock() + private var disposeBag = Set() + private var listeningTask: Task? + /// Owner of the store and of the per-lane counts. Internal for @testable injection. + var tasksDataManager: TasksDataManager! + private var _lastSyncError: SyncErrorInfo? + /// Last sync error information for debugging. Writers hop to main, but readers + /// (SyncService.getLastSyncError) call from arbitrary threads — same lock + /// treatment as accessPolicy so the cross-thread read isn't a data race. + public private(set) var lastSyncError: SyncErrorInfo? { + get { + policyLock.withLock { _lastSyncError } + } + set { + policyLock.withLock { _lastSyncError = newValue } + } + } + // Services + + required public init(maxConcurrentTasks: Int = 4) { + self.operationQueue = OperationQueue() + self.operationQueue.name = "com.bookplayer.synctask.concurrent" + // This still caps the total number of operations running simultaneously across all keys + self.operationQueue.maxConcurrentOperationCount = maxConcurrentTasks + } + + deinit { listeningTask?.cancel() } + + /// The service's ONLY account dependency — one read, so a closure instead of the + /// whole AccountServiceProtocol (same narrowing as the import bus). Internal for + /// @testable injection. + var getAccessLevel: (() -> AccessLevel)! + + public func setup( + libraryService: LibrarySyncProtocol, + getAccessLevel: @escaping () -> AccessLevel, + verifySyncEntitlement: @escaping () async -> Bool?, + tasksDataManager: TasksDataManager, + networkClient: NetworkClientProtocol, + dataManager: DataManager + ) { + self.libraryService = libraryService + self.getAccessLevel = getAccessLevel + self.verifySyncEntitlement = verifySyncEntitlement + self.networkClient = networkClient + self.dataManager = dataManager + self.taskContainer = SyncQueueRepository(tasksDataManager: tasksDataManager) + self.tasksDataManager = tasksDataManager + startListeningForNewTasks() + bindObservers() + bindAccountObserver() + // Policy BEFORE workers: createOperation consults accessPolicy to decide whether a + // persisted upload may run — waking workers first only worked because wakeUpWorkers + // happens to suspend on the repository actor before any pop + updateAccessPolicy(getAccessLevel()) + // The one automatic retry of parked tasks: a server-side fix (or a fixed app build) + // gets its chance on the next launch, without retrying anything in between + wakeUpWorkers(resumingPausedTasks: true) + } + + /// Ownership norm (same as SyncService): the service re-derives its own per-job + /// policy on account changes — no per-platform coordinator wiring to forget. + /// RevenueCat updates post .accountUpdate; without this, a mid-session upgrade left + /// the launch-time policy (file uploads still gated off) until the next app start. + func bindAccountObserver() { + NotificationCenter.default.publisher(for: .accountUpdate, object: nil) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + self.updateAccessPolicy(self.getAccessLevel()) + } + .store(in: &disposeBag) + } + + func bindObservers() { + NotificationCenter.default.publisher(for: .logout, object: nil) + .sink(receiveValue: { [weak self] _ in + UserDefaults.standard.set( + false, + forKey: Constants.UserDefaults.hasScheduledLibraryContents + ) + // Persisted rows are wiped by resetAllJobs, but an IN-FLIGHT operation would keep + // running (and uploading) under the next signed-in account's token without this. + self?.operationQueue.cancelAllOperations() + }) + .store(in: &disposeBag) + + libraryService.progressUpdatePublisher.sink { [weak self] params in + self?.scheduleMetadataUpdate(params: params) + } + .store(in: &disposeBag) + } + + private func startListeningForNewTasks() { + // [weak self]: the for-await loop never terminates on its own, so a strong capture makes + // the service retain itself through its task and deinit (which cancels it) never runs. + listeningTask = Task { [weak self] in + let stream = NotificationCenter.default.notifications(named: .newTaskInQueue) + + for await notification in stream { + guard let self else { return } + guard let userInfo = notification.userInfo, + let queueKey = userInfo["queueKey"] as? String else { + continue + } + + // Wake up the worker! + await startWorkerLoop(for: queueKey) + } + } + } + + /// Call this when the app wakes up, or when a new task is added to the database + func wakeUpWorkers(resumingPausedTasks: Bool = false) { + // Get all unique queue keys that currently have pending tasks + Task { + if resumingPausedTasks { + await taskContainer.resumeAllPaused() + } + // Now you can safely await the actor! + let pendingKeys = await taskContainer.getAllQueueKeys() + + for key in pendingKeys { + await startWorkerLoop(for: key) + } + } + } + + private func startWorkerLoop(for queueKey: String) async { + // 1. Use scoped locking to check and update the state safely + let isAlreadyRunning = stateLock.withLock { + // This entire block is perfectly thread-safe and synchronous + if activeQueueKeys.contains(queueKey) { + return true + } else { + activeQueueKeys.insert(queueKey) + return false + } + } + + // 2. If it was already running, safely bail out + guard !isAlreadyRunning else { return } + + // 3. Now we are safely outside the lock, so we can await! + await enqueueNextTask(for: queueKey) + } + + public func observeQueueCounts() -> AnyPublisher { + return tasksDataManager.observeQueueCounts() + } + + public var serverLanesEnabled: Bool { + stateLock.withLock { _serverLanesEnabled } + } + + public func setServerLanesEnabled(_ enabled: Bool) { + let changed = stateLock.withLock { + defer { _serverLanesEnabled = enabled } + return _serverLanesEnabled != enabled + } + // Turning off needs no action here: each worker retires at its next pop + if changed, enabled { + wakeUpWorkers() + } + } + + private func enqueueNextTask(for queueKey: String) async { + // Checked before every pop, not just at wake-up, so a worker already looping when + // sync turns off stops after its current task instead of draining the lane + if TaskQueueKey.isServerLane(queueKey) { + let retired = stateLock.withLock { + guard !_serverLanesEnabled else { return false } + activeQueueKeys.remove(queueKey) + return true + } + // Checking the flag and retiring under one lock means an enable either happened + // before (we keep going) or will see this key inactive and wake a fresh worker + if retired { return } + } + + // 1. AWAIT the actor to safely fetch the next task + guard let nextTask = await taskContainer.getNextTask(for: queueKey) else { + // The queue is empty! Use scoped locking to remove the key. + let _ = stateLock.withLock { + activeQueueKeys.remove(queueKey) + } + // Re-check after retiring: a task stored between the empty fetch and the + // removal above would have seen an "active" worker and skipped waking one. + // A peek: getNextTask would mark a task in flight while another worker may be + // running a different one + if await taskContainer.hasRunnableTask(for: queueKey) { + await startWorkerLoop(for: queueKey) + } + return + } + guard let operation = createOperation(for: nextTask) else { + Task { + await self.taskContainer.pop(nextTask) + await self.enqueueNextTask(for: queueKey) + } + return + } + + operation.onProgress = { progress in + Task { @MainActor in + SyncQueueProgressMonitor.shared.updateProgress(for: nextTask.id, progress: progress) + // Three consumers (SyncJobScheduler, the profile task views) still listen for this + // notification with a {uuid, relativePath, progress} payload — the old poster was + // removed with LibraryItemSyncOperation's upload path, silently freezing every + // upload progress bar. + NotificationCenter.default.post( + name: .uploadProgressUpdated, + object: nil, + userInfo: [ + "uuid": nextTask.uuid, + "relativePath": nextTask.relativePath, + "progress": progress, + ] + ) + } + } + + operation.completionBlock = { [weak self, weak operation] in + // Resolve the weak refs SYNCHRONOUSLY: the queue keeps the operation alive while its + // completionBlock runs, but the async Task below executes later — resolving there + // could miss the operation and skip the pop, re-running the task forever. The weak + // capture itself breaks the operation→completionBlock→operation cycle the strong + // capture created. + guard let self, let operation else { return } + // 2. Bridge back into the async world inside the synchronous completion block + Task { + + if operation.didSucceed { + await self.handleFinishedOperation(operation, task: nextTask) + await self.taskContainer.pop(nextTask) + } else { + let error = (operation as? LibraryItemSyncOperation)?.error + ?? (operation as? FileUploadOperation)?.error + if let error { + Self.logger.error("Sync task failed: \(error.localizedDescription)") + await MainActor.run { + self.lastSyncError = SyncErrorInfo( + taskId: nextTask.id, + uuid: nextTask.uuid, + jobType: nextTask.jobType, + error: error.localizedDescription + ) + } + } + await self.handleFailedOperation(error: error, task: nextTask) + } + + // 3. AWAIT the recursive call + await MainActor.run { + SyncQueueProgressMonitor.shared.clear(taskID: nextTask.id) + } + await self.enqueueNextTask(for: queueKey) + } + } + + await MainActor.run { + SyncQueueProgressMonitor.shared.markAsProcessing(taskID: nextTask.id) + } + + operationQueue.addOperation(operation) + } + + /// Retry (the usual 5 s), park, drop, or confirm the account, per `SyncFailurePolicy`. + /// Returns once the next pop may happen. + private func handleFailedOperation(error: Error?, task: QueuedSyncTask) async { + // The server has no such book for this upload: register it again (bookplayer-api + // docs/multipart-uploads.md) rather than park a task the user can't fix + if task.jobType == .uploadFile, + SyncFailurePolicy.codedFailure(error)?.code == "item_not_found", + stateLock.withLock({ handedBackUuids.insert(task.uuid).inserted }) { + await handBackUpload(task) + try? await Task.sleep(for: .seconds(5)) + return + } + + let action = SyncFailurePolicy.action( + for: error, + jobType: task.jobType, + parkingEnabled: parkingEnabled + ) + guard action != .retry, let failure = SyncFailurePolicy.codedFailure(error) else { + try? await Task.sleep(for: .seconds(5)) + return + } + let code = failure.code + let message = failure.message + let status = failure.httpStatus + + switch action { + case .retry: + break + case .drop: + Self.logger.error("Dropping \(task.jobType.rawValue) task \(task.id): the server answered \(code)") + await taskContainer.pop(task) + case .park(let scope): + // A too-large book is the app's own limit, not a failure: nothing to report + await park( + task, + scope: scope, + code: code, + message: message, + status: status, + report: code != UploadFileError.fileTooLarge.code + ) + case .verifyAccount: + // Every server lane holds from here: the rejection is about the account, so the + // next task would get the same answer + guard + let pause = await park(task, scope: .account, code: code, message: message, status: status, report: false) + else { return } + // Off the worker: the lanes already wait on the pause + Task { + // Inactive: the fetch's account update runs the lapse path, which clears these + // lanes. Active, or the check failed: the server disagrees with RevenueCat, so the + // tasks stay held (launch retry, Retry) and it's reported. + guard await self.verifySyncEntitlement() != false else { return } + self.reportPause(of: task, pause: pause) + } + } + } + + /// Re-registers the book through the sync lane like a fresh upload — the item, its + /// external resources and bookmarks (`SyncService.handleItemsToUpload`); the `.upload`'s + /// answer schedules a new upload task — or drops the upload when the book is gone + /// locally too. The replacement is stored before this task is popped, so a kill in + /// between can't lose the upload. + private func handBackUpload(_ task: QueuedSyncTask) async { + // Before the new job links the file again at the same path + cleanUpDroppedUploadTempLink(task) + guard let item = await findSyncableItem(task.uuid) else { + Self.logger.info("Dropping upload \(task.id): the book is gone on the server and on this device") + await taskContainer.pop(task) + return + } + Self.logger.info("Upload \(task.id): the server lost the book, registering it again") + let scheduler = SyncJobScheduler(tasksRepository: taskContainer) + await scheduler.scheduleLibraryItemUploadJob(for: item) + let itemOrigin = LibraryItemRef(relativePath: item.relativePath, uuid: item.uuid) + for resource in item.externalResources ?? [] { + await scheduler.scheduleExternalResourceUpload(for: resource, itemOrigin: itemOrigin) + } + // Bookmarks are read on the view context + let libraryService = libraryService + let bookmarks = await MainActor.run { + libraryService?.getBookmarks(of: .user, relativePath: item.relativePath) ?? [] + } + for bookmark in bookmarks { + await scheduler.scheduleSetBookmarkJob( + with: bookmark.relativePath, + time: floor(bookmark.time), + note: bookmark.note, + for: item.uuid + ) + } + // A media-server book's metadata upload never schedules its file (streamed books have + // none yet): queue the file last, the way a finished download does + if item.mediaServerProviderName != nil { + await scheduler.scheduleResourceToDownload(with: item.relativePath, for: item.uuid) + } + await taskContainer.pop(task) + } + + @discardableResult + private func park( + _ task: QueuedSyncTask, + scope: TaskPauseScope, + code: String, + message: String, + status: Int?, + report: Bool = true + ) async -> TaskPause? { + Self.logger.error("Pausing \(task.jobType.rawValue) task \(task.id) (\(scope.rawValue)): failed with \(code)") + guard + let pause = await taskContainer.park( + taskId: task.id, + pause: TaskPause(scope: scope, errorCode: code, message: message, httpStatus: status, pausedAt: Date()) + ) + else { return nil } + if report { + reportPause(of: task, pause: pause) + } + return pause + } + + /// Once per task: a re-park after a launch retry or Retry was already reported + private func reportPause(of task: QueuedSyncTask, pause: TaskPause) { + guard pause.sentryEventId == nil else { return } + let parked = QueuedSyncTask( + id: task.id, + queueKey: task.queueKey, + jobType: task.jobType, + parameters: [:], + uuid: task.uuid, + relativePath: task.relativePath, + pause: pause + ) + Task { @MainActor in + NotificationCenter.default.post(name: .syncTaskPaused, object: parked) + } + } + + public func retryPausedTask(id: String) async { + // An explicit Retry of an upload asks for the book again: it gets another + // re-registration even if it already had one this session + if let task = await taskContainer.getAllTasks().first(where: { $0.id == id }), + task.jobType == .uploadFile, + task.pause != nil { + stateLock.withLock { _ = handedBackUuids.remove(task.uuid) } + } + await taskContainer.resume(taskId: id) + wakeUpWorkers() + } + + public var queueCounts: QueueCounts { tasksDataManager.currentQueueCounts } + + public func settleUploads() async { + // Bounded: the caller also times out, but a lane that never starts mustn't spin here + for _ in 0..<100 { + guard serverLanesEnabled else { return } + let running = operationQueue.operations.first { + $0 is FileUploadOperation && $0.isExecuting && !$0.isCancelled + } as? FileUploadOperation + if let running { + // Settled while still uploading; if the book finished instead, the lane's next + // upload starts and tops up in turn + if await running.settle() { return } + continue + } + guard await taskContainer.hasRunnableTask(for: TaskQueueKey.uploadFile) else { return } + // A worker is about to start one (the lane was just woken) + try? await Task.sleep(for: .milliseconds(200)) + } + } + + public func pendingBookUploads() async -> PendingBookUploads { + // One actor call: both lanes read at the same instant + let candidates = await taskContainer.getUploadCandidates().filter { candidate in + switch candidate.task.jobType { + case .upload: + let type = (candidate.task.parameters["type"] as? Int16).flatMap(SimpleItemType.init(rawValue:)) + // A media-server book's metadata upload schedules no file + return type == .book && candidate.task.parameters["provider"] == nil + case .externalResourceToDownload, .uploadFile: + return true + default: + return false + } + } + + var seen = Set() + var parkedCount = 0 + let books = candidates.compactMap { candidate -> PendingBookUpload? in + let job = candidate.task + guard seen.insert(job.uuid).inserted else { return nil } + guard !candidate.isParked else { + parkedCount += 1 + return nil + } + let temporaryLink = (job.parameters["filePath"] as? String).flatMap(URL.init(string:)) + ?? SyncJobScheduler.hardLinkURL(for: job.relativePath) + let files = [temporaryLink, DataManager.getProcessedFolderURL().appendingPathComponent(job.relativePath)] + let size = files.lazy.compactMap { try? FileUploadOperation.fileSize(of: $0) }.first ?? 0 + return PendingBookUpload(uuid: job.uuid, relativePath: job.relativePath, fileSize: size, queueKey: candidate.queueKey) + } + return PendingBookUploads(books: books, parkedCount: parkedCount) + } + + public func dismissPausedTask(id: String) { + Task { + guard + let task = await taskContainer.getAllTasks().first(where: { $0.id == id }), + task.pause?.errorCode == UploadFileError.fileTooLarge.code + else { return } + // The upload's own parameters name its temp link + let filePath = await taskContainer.getAllTasksWithParams(in: TaskQueueKey.uploadFile) + .first { $0.id == id }?.parameters["filePath"] as? String + await taskContainer.pop(task) + SyncJobScheduler.removeHardLink(at: filePath.flatMap(URL.init(string:))) + } + } + + public func recordPauseReport(eventId: String, forTask taskId: String) { + Task { + await taskContainer.setSentryEventId(eventId, forTask: taskId) + } + } + + public func getOrderedQueuedJobs(activeTaskIDs: Set) async -> [QueuedSyncTask] { + return await taskContainer.getOrderedTasks(activeTaskIDs: activeTaskIDs) + } + + private func createOperation(for task: QueuedSyncTask) -> AsyncOperation? { + switch task.jobType { + case .externalUpdate: + guard let providerName = task.parameters["providerName"] as? String, + let providerId = task.parameters["providerId"] as? String, + let currentTime = task.parameters["currentTime"] as? Double, + let percentCompleted = task.parameters["percentCompleted"] as? Double else { + Self.logger.error("Discarding externalUpdate task \(task.id): missing required parameters") + return nil + } + // The parameters are PERSISTED, so a poison value doesn't crash once — it + // crash-loops on every pop. Int(_:) traps on NaN/infinite AND on finite values + // beyond Int.max (CMTimeGetSeconds yields NaN for invalid CMTimes); drop the + // task instead. + let ticks = currentTime * 10_000_000 + // Strict <: Double(Int.max) rounds UP to 2^63, so the boundary itself would trap + guard ticks.isFinite, ticks >= 0, ticks < Double(Int.max) else { + Self.logger.error("Discarding externalUpdate task \(task.id): non-finite or out-of-range currentTime \(currentTime)") + return nil + } + let hostId = task.parameters["hostId"] as? String + return ExternalUpdateProgressOperation( + providerName: providerName, + providerItemId: providerId, + positionTicks: Int(ticks), + percentCompleted: percentCompleted, + hostId: hostId + ) + case .uploadFile: + // Re-check access at execution, not just at scheduling: a pro→lite downgrade keeps + // sync active (no cancelAllJobs), but persisted uploads must not keep PUTting to S3 + // on a tier without S3 access. Returning nil pops the task — same drop treatment the + // lapse path gives the upload queue. + guard accessPolicy[.uploadFile] == true else { + Self.logger.info("Dropping persisted uploadFile task \(task.id): tier has no S3 upload access") + cleanUpDroppedUploadTempLink(task) + return nil + } + guard let filePath = task.parameters["filePath"] as? String, + let fileURL = URL(string: filePath), + let uuid = task.parameters["uuid"] as? String else { + Self.logger.error("Discarding uploadFile task \(task.id): missing or malformed parameters") + cleanUpDroppedUploadTempLink(task) + return nil + } + let libraryService = libraryService + return FileUploadOperation( + taskId: task.id, + uuid: uuid, + fileURL: fileURL, + state: MultipartUploadState(parameters: task.parameters), + client: networkClient, + repository: taskContainer, + libraryFileURL: { uuid in + guard let relativePath = await libraryService?.fetchRelativePath(forUuid: uuid) else { return nil } + return DataManager.getProcessedFolderURL().appendingPathComponent(relativePath) + } + ) + default: + /// Serial BookPlayer-server queue + return LibraryItemSyncOperation( + client: networkClient, + task: SyncTask( + id: task.id, + uuid: task.uuid, + relativePath: task.relativePath, + jobType: task.jobType, + parameters: task.parameters + ), + canUploadFiles: accessPolicy[.uploadFile] == true + ) + } + } + + /// Post-completion side effects for finished sync tasks + private func handleFinishedOperation(_ operation: AsyncOperation, task: QueuedSyncTask) async { + // The server sets synced:true itself when it assembles a multipart upload; there is + // nothing to confirm. uploadCompleted (S3 has the file), NOT didSucceed: a consumed + // task (its file gone) also reports didSucceed. + if let uploadOperation = operation as? FileUploadOperation { + if uploadOperation.uploadCompleted { + NotificationCenter.default.post(name: .uploadCompleted, object: nil) + } + return + } + guard + let syncOperation = operation as? LibraryItemSyncOperation, + let results = syncOperation.results + else { return } + + switch results { + case .matchUuid(let response): + await handleMatchUuidsResponse(response) + case .uploadMetadata(let result): + /// Provider-backed items don't upload their file from the metadata upload: it goes + /// up once downloaded, through the `externalResourceToDownload` job (which carries no + /// `provider`, so its result schedules the upload here) + if task.parameters["provider"] as? String == nil { + // Stored before the caller pops this task: the book is never in neither lane (a kill + // in between would lose the upload, and the continued task would count it done) + await handleUploadResult(result) + } else { + SyncJobScheduler.removeHardLink(at: URL(string: result.filePath)) + } + } + } + + public func applyUuidConflicts(_ conflicts: [ItemConflict]) async throws { + // The same local uuid can come back twice (two rows sharing it): adopt the first answer + var seen = Set() + let conflicts = conflicts.filter { seen.insert($0.key).inserted } + guard !conflicts.isEmpty else { return } + try await applyCoreDataConflicts(conflicts) + try await taskContainer.applyMatchUuidConflicts(conflicts) + } + + private func handleMatchUuidsResponse(_ results: MatchUuidsResponse) async { + guard !results.conflicts.isEmpty else { return } + do { + try await applyUuidConflicts(results.conflicts) + } catch { + Self.logger.error("Failed to apply matchUuid conflicts: \(error.localizedDescription)") + await MainActor.run { + self.lastSyncError = SyncErrorInfo( + taskId: "", + uuid: "", + jobType: .matchUuid, + error: error.localizedDescription + ) + } + } + } + + private func applyCoreDataConflicts(_ conflicts: [ItemConflict]) async throws { + let context = dataManager.getBackgroundContext() + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + context.perform { + do { + let oldUuids = conflicts.map { $0.key } + let fetchRequest: NSFetchRequest = LibraryItem.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "uuid IN %@", oldUuids) + let items = try context.fetch(fetchRequest) + let uuidMap = Dictionary(uniqueKeysWithValues: conflicts.map { ($0.key, $0.uuid) }) + for item in items { + if let newUuid = uuidMap[item.uuid] { + item.uuid = newUuid + } + } + try context.save() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } + + /// The server answered the metadata upload with a URL: it needs the file's bytes + private func handleUploadResult(_ result: UploadResponse) async { + var params: [String: Any] = [ + "filePath": result.filePath, + "uuid": result.uuid, + ] + // Persisted onto the task reference, which names the row in Queued Tasks + if let relativePath = result.relativePath { + params["relativePath"] = relativePath + } + await storeFileUpload(params: params) + } + + public func cancelServerQueueOperations() { + for operation in operationQueue.operations + where operation is FileUploadOperation || operation is LibraryItemSyncOperation { + operation.cancel() + } + } + + func updateAccessPolicy(_ accessLevel: AccessLevel) { + switch accessLevel { + case .lite: + accessPolicy = [ + .externalUpdate: true, + .uploadFile: false, + ] + case .pro: + accessPolicy = [ + .externalUpdate: true, + .uploadFile: true, + ] + default: + // Progress pushes go to the USER'S OWN media server, not a BookPlayer-billed resource — + // they stay available on every tier (parity with the Android app, where the media-server + // queues are deliberately exempt from entitlement gating). + accessPolicy = [ + .externalUpdate: true, + .uploadFile: false, + ] + } + // A downgrade that loses S3 access (pro→lite keeps sync active, so no cancelAllJobs + // fires) must also stop uploads that are ALREADY running, not just future pops + if accessPolicy[.uploadFile] != true { + for operation in operationQueue.operations where operation is FileUploadOperation { + operation.cancel() + } + } + } + + /// A dropped uploadFile task never constructs its FileUploadOperation, so nobody else + /// removes the schedule-time temp hard link — same temp-dir-only guard as the + /// operation's own success/4xx/cancel cleanup paths (a real Processed-folder file + /// must never be deleted here). + private func cleanUpDroppedUploadTempLink(_ task: QueuedSyncTask) { + SyncJobScheduler.removeHardLink(at: (task.parameters["filePath"] as? String).flatMap(URL.init(string:))) + } +} + +extension SyncQueueService { + public func scheduleMetadataUpdate(params: [String: Any]) { + guard accessPolicy[.externalUpdate] == true else { + return + } + Task { + guard let queueKey = params["providerName"] as? String else { + return + } + + var params = params + params["id"] = UUID().uuidString + params["jobType"] = SyncJobType.externalUpdate.rawValue + params["queueKey"] = queueKey + /// Override param `lastPlayDate` if it exists with the proper name + if let lastPlayDate = params.removeValue(forKey: #keyPath(LibraryItem.lastPlayDate)) { + params["lastPlayDateTimestamp"] = lastPlayDate + } + + do { + try await taskContainer.storeTask(parameters: params) + } catch { + Self.logger.error("Failed to schedule metadata update task: \(error)") + } + } + } + + public func scheduleFileUpload(params: [String: Any]) { + guard accessPolicy[.uploadFile] == true else { + /// No upload will read the temp hard link scheduled for it + SyncJobScheduler.removeHardLink(at: (params["filePath"] as? String).flatMap(URL.init(string:))) + return + } + + Task { await storeFileUpload(params: params) } + } + + public func scheduleMissingFileUploads(_ uploads: [MissingFileUpload]) async -> Int { + guard accessPolicy[.uploadFile] == true, !uploads.isEmpty else { return 0 } + // The Processed file itself, not a temp hard link: the engine never deletes a source + // outside tmp, and finds the book again by uuid if it moves + let parameterList: [[String: Any]] = uploads.map { upload in + [ + "id": UUID().uuidString, + "jobType": SyncJobType.uploadFile.rawValue, + "queueKey": TaskQueueKey.uploadFile, + "uuid": upload.uuid, + "relativePath": upload.relativePath, + "filePath": upload.fileURL.absoluteString, + ] + } + do { + return try await taskContainer.storeFileUploadsIfAbsent(parameterList) + } catch { + Self.logger.error("Failed to queue the missing file uploads: \(error)") + return 0 + } + } + + /// `scheduleFileUpload`, awaited: returns once the task is stored + private func storeFileUpload(params: [String: Any]) async { + guard accessPolicy[.uploadFile] == true else { + SyncJobScheduler.removeHardLink(at: (params["filePath"] as? String).flatMap(URL.init(string:))) + return + } + var params = params + params["id"] = UUID().uuidString + params["jobType"] = SyncJobType.uploadFile.rawValue + params["queueKey"] = TaskQueueKey.uploadFile + + do { + try await taskContainer.storeTask(parameters: params) + } catch { + Self.logger.error("Failed to schedule upload file task: \(error)") + } + } +} diff --git a/Shared/Services/SyncQueue/TaskPause.swift b/Shared/Services/SyncQueue/TaskPause.swift new file mode 100644 index 000000000..ee8d8e7d0 --- /dev/null +++ b/Shared/Services/SyncQueue/TaskPause.swift @@ -0,0 +1,118 @@ +// +// TaskPause.swift +// BookPlayer +// +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import Foundation + +/// How much of the queue a parked task holds back +public enum TaskPauseScope: String { + /// Only this task waits; the rest of its lane keeps running (leaf tasks nothing + /// later depends on) + case task + /// The whole lane waits behind it: running later tasks would build on a change the + /// server never got (structural sync-lane tasks) + case lane + /// Every BookPlayer-server lane waits: the server rejected the account itself + case account +} + +/// Why a task stopped: the server's coded answer, kept for the Queued Tasks row and Report +public struct TaskPause: Equatable { + public let scope: TaskPauseScope + public let errorCode: String + /// The server's message. Shown to the user and attached to Report, never sent to Sentry + /// (it embeds file names) + public let message: String + public let httpStatus: Int? + public let pausedAt: Date + public let sentryEventId: String? + + public init( + scope: TaskPauseScope, + errorCode: String, + message: String, + httpStatus: Int?, + pausedAt: Date, + sentryEventId: String? = nil + ) { + self.scope = scope + self.errorCode = errorCode + self.message = message + self.httpStatus = httpStatus + self.pausedAt = pausedAt + self.sentryEventId = sentryEventId + } +} + +/// What the queue does with a failed task +public enum SyncFailureAction: Equatable { + /// Uncoded or transient: retry after the usual delay, as always + case retry + /// Stop the task with this scope until a launch retry or the user's Retry + case park(TaskPauseScope) + /// Coded failure where nobody can see a paused task (watch): log and drop it + case drop + /// The server rejected the account: confirm against RevenueCat, holding every + /// server lane meanwhile + case verifyAccount +} + +/// A failure that says it can never succeed as it is: the server's coded 4xx, or a book +/// the app itself refuses to upload +public struct CodedFailure: Equatable { + public let code: String + public let message: String + public let httpStatus: Int? +} + +public enum SyncFailurePolicy { + /// Codes about the account, not the task + static let accountCodes: Set = ["not_subscribed", "tier_required"] + + /// nil for anything uncoded (network, 5xx, cancellation): those keep retrying + public static func codedFailure(_ error: Error?) -> CodedFailure? { + if let error = error as? UploadFileError { + return CodedFailure(code: error.code, message: error.message, httpStatus: nil) + } + guard + let error = error as? BookPlayerError, + case .networkErrorWithCode(let message, let code, let status) = error + else { return nil } + return CodedFailure(code: code, message: message, httpStatus: status) + } + + /// Only a coded failure parks: a code means the request can never succeed as sent. + /// Anything uncoded keeps retrying, so a new server failure mode never strands tasks. + public static func action( + for error: Error?, + jobType: SyncJobType, + parkingEnabled: Bool + ) -> SyncFailureAction { + guard let code = codedFailure(error)?.code else { return .retry } + + // Media-server pushes never hit the BookPlayer server; their own failure handling + // stands, and an account pause must never land on a provider lane + if jobType == .externalUpdate { return .retry } + + if accountCodes.contains(code) { return .verifyAccount } + + guard parkingEnabled else { return .drop } + return .park(scope(for: jobType)) + } + + /// Leaf tasks park alone: nothing later in the queue depends on them. Every other + /// sync-lane task changes structure (what exists, where, under which uuid) that later + /// tasks build on, so the lane stops behind it. + public static func scope(for jobType: SyncJobType) -> TaskPauseScope { + switch jobType { + case .update, .uploadArtwork, .uploadFile, .externalUpdate: + return .task + case .upload, .move, .renameFolder, .delete, .shallowDelete, .setBookmark, .deleteBookmark, + .matchUuid, .externalResource, .externalResourceToDownload, .deleteExternalResource: + return .lane + } + } +} diff --git a/Shared/SwiftData/MigrationPlan.swift b/Shared/SwiftData/MigrationPlan.swift index 96e3862d4..e8567e4c4 100644 --- a/Shared/SwiftData/MigrationPlan.swift +++ b/Shared/SwiftData/MigrationPlan.swift @@ -9,13 +9,13 @@ import Foundation import SwiftData import CoreData -public enum MigrationPlan: SchemaMigrationPlan { +public enum MigrationPlan: SchemaMigrationPlan, BPLogger { public static var schemas: [any VersionedSchema.Type] { - [SchemaV1.self, SchemaV2.self] + [SchemaV1.self, SchemaV2.self, SchemaV3.self] } public static var stages: [MigrationStage] { - [v1ToV2] + [v1ToV2, v2ToV3] } public static var injectedCoreDataContext: NSManagedObjectContext? @@ -58,7 +58,8 @@ public enum MigrationPlan: SchemaMigrationPlan { var uuidsDict: [String: String] = [:] coreDataContext.performAndWait { let fetchRequest = NSFetchRequest(entityName: "LibraryItem") - // Fetch only items where the UUID hasn't been set yet + // Unfiltered paginated scan (offset/limit) — every item is visited; the + // per-item logic below decides what to write fetchRequest.fetchLimit = 200 fetchRequest.fetchOffset = previousOffset fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \LibraryItem.relativePath, ascending: true)] @@ -106,4 +107,157 @@ public enum MigrationPlan: SchemaMigrationPlan { try context.save() } ) + + /// Stash of the V2 sync-task references captured in `willMigrate` and replayed into + /// the unified container in `didMigrate`. Needed because SchemaV3 drops the V2 + /// `SyncTasksContainer`/`SyncTaskReferenceModel` entities (their rows are discarded + /// by the schema migration itself). The payload task models carry over untouched. + private static var stashedSyncReferences: + [(uuid: String, relativePath: String, taskID: String, jobType: SyncJobType, position: Int)] = [] + + // Stage 3: Move the V2 sync queue into the unified concurrent container (under the + // "sync" queue key), then backfill external resources for items that were linked to + // a Hardcover book before hardcover links were modeled as external resources, and + // enqueue an upload task for each so the link reaches the server. + static var v2ToV3: MigrationStage = MigrationStage.custom( + fromVersion: SchemaV2.self, + toVersion: SchemaV3.self, + willMigrate: { context in + stashedSyncReferences = [] + let containers = try context.fetch(FetchDescriptor()) + guard let tasksContainer = containers.first else { return } + + stashedSyncReferences = tasksContainer.orderedTasks.map { + ( + uuid: $0.uuid, + relativePath: $0.relativePath, + taskID: $0.taskID, + jobType: $0.jobType, + position: $0.position + ) + } + }, + didMigrate: { context in + let descriptor = FetchDescriptor() + let containers = try context.fetch(descriptor) + let tasksContainer = containers.first ?? SchemaV3.SyncQueueContainer() + if containers.isEmpty { + context.insert(tasksContainer) + } + + // 1. Replay the stashed V2 sync queue, preserving its FIFO order. + var nextPosition = (tasksContainer.tasks.map(\.position).max() ?? -1) + 1 + for reference in stashedSyncReferences.sorted(by: { $0.position < $1.position }) { + let migrated = SchemaV3.QueuedTaskReferenceModel( + queueKey: TaskQueueKey.sync, + taskID: reference.taskID, + jobType: reference.jobType, + position: nextPosition, + uuid: reference.uuid, + relativePath: reference.relativePath + ) + tasksContainer.tasks.append(migrated) + migrated.container = tasksContainer + nextPosition += 1 + } + stashedSyncReferences = [] + try context.save() + + // 2. Hardcover external-resource backfill. + // Deliberately graceful (unlike v1ToV2's fatalError): a missing context here only + // skips the OPTIONAL backfill of hardcover resources — losing that beats crashing + // an upgrade, whereas v1ToV2 without the context would corrupt real queued tasks. + guard let coreDataContext = injectedCoreDataContext else { + return + } + + let providerName = ExternalResource.ProviderName.hardcover.rawValue + let syncStatus = ExternalResource.SyncStatus.notSynced.rawValue + + var resourcesToUpload: [(uuid: String, relativePath: String, providerId: String)] = [] + + // Create the missing hardcover external resources in Core Data. + coreDataContext.performAndWait { + let fetchRequest = NSFetchRequest(entityName: "LibraryItem") + fetchRequest.predicate = NSPredicate(format: "hardcoverBook != nil") + + guard let items = try? coreDataContext.fetch(fetchRequest) else { return } + + var pageResources: [(uuid: String, relativePath: String, providerId: String)] = [] + for item in items { + guard let hardcoverBook = item.hardcoverBook else { continue } + let providerId = String(hardcoverBook.id) + + // Skip if the resource already exists + if item.resourcesArray.contains(where: { + $0.providerName == providerName && $0.providerId == providerId + }) { + continue + } + + let syncable = SyncableExternalResource( + providerName: providerName, + providerId: providerId, + syncStatus: syncStatus, + lastSyncedAt: nil, + processedFile: true, + hostId: nil + ) + _ = ExternalResource.create(syncable, libraryItem: item, in: coreDataContext) + + pageResources.append( + (uuid: item.uuid, relativePath: item.relativePath, providerId: providerId) + ) + } + + do { + try coreDataContext.save() + // Enqueue ONLY rows that actually persisted: a save failure is self-healing + // (the resource returns on the next sync), but an upload task pointing at a + // resource that never persisted locally would fail/retry pointlessly. + resourcesToUpload.append(contentsOf: pageResources) + } catch { + Self.logger.error("v2ToV3 external-resource backfill save failed; skipping \(pageResources.count) enqueue(s): \(error)") + } + } + + // Only enqueue upload tasks for users who already have a server-side library; + // resources for never-synced users are uploaded on their first sync. + guard + UserDefaults.standard.bool(forKey: Constants.UserDefaults.hasScheduledLibraryContents), + !resourcesToUpload.isEmpty + else { + return + } + + for resource in resourcesToUpload { + let taskId = UUID().uuidString + let task = SchemaV3.UploadExternalResourceTaskModel( + id: taskId, + uuid: resource.uuid, + providerId: resource.providerId, + providerName: providerName, + lastSyncedAt: nil, + syncStatus: syncStatus, + processedFile: true, + hostId: nil + ) + context.insert(task) + + let reference = SchemaV3.QueuedTaskReferenceModel( + queueKey: TaskQueueKey.sync, + taskID: taskId, + jobType: .externalResource, + position: nextPosition, + uuid: resource.uuid, + relativePath: resource.relativePath + ) + tasksContainer.tasks.append(reference) + reference.container = tasksContainer + nextPosition += 1 + } + + try context.save() + } + ) } diff --git a/Shared/SwiftData/Models/SchemaV3SyncTasksModels.swift b/Shared/SwiftData/Models/SchemaV3SyncTasksModels.swift new file mode 100644 index 000000000..8f6384665 --- /dev/null +++ b/Shared/SwiftData/Models/SchemaV3SyncTasksModels.swift @@ -0,0 +1,457 @@ +// +// SchemaV3SyncTasksModels.swift +// BookPlayer +// +// Created by Pedro Iñiguez on 20/3/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import SwiftData +import Foundation + +// MARK: - Schema V3 (The Container) +public enum SchemaV3: VersionedSchema { + public static var versionIdentifier = Schema.Version(3, 0, 0) + + // List EVERY model in your app here + public static var models: [any PersistentModel.Type] { + [ + UploadTaskModel.self, + UpdateTaskModel.self, + MoveTaskModel.self, + DeleteTaskModel.self, + DeleteBookmarkTaskModel.self, + SetBookmarkTaskModel.self, + RenameFolderTaskModel.self, + ArtworkUploadTaskModel.self, + MatchUuidsTaskModel.self, + UploadExternalResourceTaskModel.self, + SyncQueueContainer.self, + QueuedTaskReferenceModel.self, + ExternalUpdateTaskModel.self, + UploadFileTaskModel.self, + ExternalResourceToDownloadTaskModel.self, + DeleteExternalResourceTaskModel.self + ] + } + + @Model + public class UploadTaskModel { + @Attribute(.unique) public var id: String + public var relativePath: String + public var originalFileName: String + public var title: String + public var details: String + public var speed: Double? + public var currentTime: Double + public var duration: Double + public var percentCompleted: Double + public var isFinished: Bool + public var orderRank: Int + public var lastPlayDateTimestamp: Double? + public var type: Int16 + public var uuid: String = UUID().uuidString + public var provider: String? = nil + + public init( + id: String, + uuid: String, + relativePath: String, + originalFileName: String, + title: String, + details: String, + speed: Float? = nil, + currentTime: Double, + duration: Double, + percentCompleted: Double, + isFinished: Bool, + orderRank: Int, + lastPlayDateTimestamp: Double? = nil, + type: Int16, + provider: String? = nil + ) { + self.id = id + self.uuid = uuid + self.relativePath = relativePath + self.originalFileName = originalFileName + self.title = title + self.details = details + if let speed { + self.speed = Double(speed) + } else { + self.speed = nil + } + self.currentTime = currentTime + self.duration = duration + self.percentCompleted = percentCompleted + self.isFinished = isFinished + self.orderRank = orderRank + self.lastPlayDateTimestamp = lastPlayDateTimestamp + self.type = type + self.provider = provider + } + } + + @Model + public class UpdateTaskModel { + @Attribute(.unique) public var id: String + public var relativePath: String + public var title: String? + public var details: String? + public var speed: Double? + public var currentTime: Double? + public var duration: Double? + public var percentCompleted: Double? + public var isFinished: Bool? + public var orderRank: Int16? + public var lastPlayDateTimestamp: Double? + public var type: Int16? + public var uuid: String = UUID().uuidString + + public init( + id: String, + uuid: String, + relativePath: String, + title: String? = nil, + details: String? = nil, + speed: Float? = nil, + currentTime: Double? = nil, + duration: Double? = nil, + percentCompleted: Double? = nil, + isFinished: Bool? = nil, + orderRank: Int16? = nil, + lastPlayDateTimestamp: Double? = nil, + type: Int16? = nil + ) { + self.id = id + self.uuid = uuid + self.relativePath = relativePath + self.title = title + self.details = details + if let speed { + self.speed = Double(speed) + } else { + self.speed = nil + } + self.currentTime = currentTime + self.duration = duration + self.percentCompleted = percentCompleted + self.isFinished = isFinished + self.orderRank = orderRank + self.lastPlayDateTimestamp = lastPlayDateTimestamp + self.type = type + } + } + + @Model + public class MoveTaskModel { + @Attribute(.unique) public var id: String + public var relativePath: String + public var origin: String + public var destination: String + public var uuid: String = UUID().uuidString + + public init(id: String, uuid: String, relativePath: String, origin: String, destination: String) { + self.id = id + self.uuid = uuid + self.relativePath = relativePath + self.origin = origin + self.destination = destination + } + } + + @Model + public class DeleteTaskModel { + @Attribute(.unique) public var id: String + public var relativePath: String + /// Can only be `delete` or `shallowDelete` + public var jobType: SyncJobType + public var uuid: String = UUID().uuidString + + public init(id: String, uuid: String, relativePath: String, jobType: SyncJobType) { + self.id = id + self.uuid = uuid + self.relativePath = relativePath + self.jobType = jobType + } + } + + @Model + public class DeleteBookmarkTaskModel { + @Attribute(.unique) public var id: String + public var relativePath: String + public var time: Double + public var uuid: String = UUID().uuidString + + public init(id: String = UUID().uuidString, uuid: String, relativePath: String, time: Double) { + self.id = id + self.uuid = uuid + self.relativePath = relativePath + self.time = time + } + } + + @Model + public class SetBookmarkTaskModel { + @Attribute(.unique) public var id: String + public var relativePath: String + public var time: Double + public var note: String? + public var uuid: String = UUID().uuidString + + public init(id: String, uuid: String, relativePath: String, time: Double, note: String? = nil) { + self.id = id + self.uuid = uuid + self.relativePath = relativePath + self.time = time + self.note = note + } + } + + @Model + public class RenameFolderTaskModel { + @Attribute(.unique) public var id: String + public var relativePath: String + public var name: String + public var uuid: String = UUID().uuidString + + public init(id: String, uuid: String, relativePath: String, name: String) { + self.id = id + self.uuid = uuid + self.relativePath = relativePath + self.name = name + } + } + + @Model + public class ArtworkUploadTaskModel { + @Attribute(.unique) public var id: String + public var relativePath: String + public var uuid: String = UUID().uuidString + + public init(id: String, uuid: String, relativePath: String) { + self.id = id + self.uuid = uuid + self.relativePath = relativePath + } + } + + @Model + public class MatchUuidsTaskModel { + @Attribute(.unique) public var id: String + public var uuids: [String: String] + + public init(id: String, uuids: [String: String]) { + self.id = id + self.uuids = uuids + } + } + + @Model + public class UploadExternalResourceTaskModel { + @Attribute(.unique) public var id: String + public var providerId: String + public var providerName: String + public var lastSyncedAt: Date? + public var syncStatus: String + public var processedFile: Bool + public var hostId: String? + public var uuid: String + + public init( + id: String, + uuid: String, + providerId: String, + providerName: String, + lastSyncedAt: Date?, + syncStatus: String, + processedFile: Bool, + hostId: String? = nil + ) { + self.id = id + self.uuid = uuid + self.providerId = providerId + self.providerName = providerName + self.lastSyncedAt = lastSyncedAt + self.syncStatus = syncStatus + self.processedFile = processedFile + self.hostId = hostId + } + } + + /// Single container for every queued task, sharded by `queueKey`. + /// The `"sync"` key holds the serial BookPlayer-server queue; every other key + /// is a provider/upload queue that runs concurrently with the rest. + @Model + public class SyncQueueContainer { + @Relationship(deleteRule: .cascade, inverse: \QueuedTaskReferenceModel.container) + public var tasks: [QueuedTaskReferenceModel] = [] + + public var orderedTasks: [QueuedTaskReferenceModel] { tasks.sorted { $0.position < $1.position } } + + public var allQueueKeys: [String] { + Array(Set(tasks.map { $0.queueKey })) + } + + public func orderedTasks(for queueKey: String) -> [QueuedTaskReferenceModel] { + tasks + .filter { $0.queueKey == queueKey } + .sorted { $0.position < $1.position } + } + + public init() {} + } + + @Model + public class QueuedTaskReferenceModel { + @Attribute(.unique) public var id: String + public var taskID: String + public var jobType: SyncJobType + public var position: Int + public var queueKey: String + public var uuid: String = "" + public var relativePath: String = "" + public var container: SyncQueueContainer? + /// Parking: set when the server answered with a coded error the task can never get + /// past as sent (`TaskPauseScope` raw value); nil = pending. The rest describes it. + public var pauseScope: String? + public var errorCode: String? + public var errorMessage: String? + public var httpStatus: Int? + public var pausedAt: Date? + /// The Sentry event reporting this pause, so a re-park (launch retry, Retry) doesn't + /// report it again; kept across resumes + public var sentryEventId: String? + + public init( + id: String = UUID().uuidString, + queueKey: String, + taskID: String, + jobType: SyncJobType, + position: Int, + uuid: String = "", + relativePath: String = "" + ) { + self.id = id + self.taskID = taskID + self.jobType = jobType + self.queueKey = queueKey + self.position = position + self.uuid = uuid + self.relativePath = relativePath + } + } + + @Model + public class ExternalUpdateTaskModel { + @Attribute(.unique) public var id: String + public var title: String? + public var details: String? + public var currentTime: Double? + public var percentCompleted: Double? + public var isFinished: Bool? + public var lastPlayDateTimestamp: Double? + public var providerName: String + public var providerId: String + /// The stable host identity of the server this push belongs to. Without persisting it, + /// a reloaded task resolves no connection and the push is (correctly) discarded — i.e. + /// dropping this field silently discards EVERY persisted progress push. + public var hostId: String? + + public init( + id: String, + providerName: String, + providerId: String, + title: String? = nil, + details: String? = nil, + currentTime: Double? = nil, + percentCompleted: Double? = nil, + isFinished: Bool? = nil, + lastPlayDateTimestamp: Double? = nil, + hostId: String? = nil, + ) { + self.id = id + self.providerName = providerName + self.providerId = providerId + self.hostId = hostId + self.title = title + self.details = details + self.currentTime = currentTime + self.percentCompleted = percentCompleted + self.isFinished = isFinished + self.lastPlayDateTimestamp = lastPlayDateTimestamp + } + } + + @Model + public class UploadFileTaskModel { + @Attribute(.unique) public var id: String + public var filePath: String + public var uuid: String + /// The open multipart upload (nil until `start` answers). With it, the upload resumes + /// from S3's part list; `start` again would abort it. + public var uploadId: String? + /// Fixed for the life of `uploadId`: every part but the last is exactly this size + public var partSize: Int = 0 + /// The file's size when the upload started; `complete` must match it + public var fileSize: Int64 = 0 + /// Fresh starts after S3 lost or rejected the upload (`upload_not_found`, + /// `invalid_parts`); past the budget the task parks + public var restartCount: Int = 0 + + public init( + id: String, + uuid: String, + filePath: String, + uploadId: String? = nil, + partSize: Int = 0, + fileSize: Int64 = 0, + restartCount: Int = 0 + ) { + self.id = id + self.uuid = uuid + self.filePath = filePath + self.uploadId = uploadId + self.partSize = partSize + self.fileSize = fileSize + self.restartCount = restartCount + } + } + + @Model + public class ExternalResourceToDownloadTaskModel { + @Attribute(.unique) public var id: String + public var uuid: String + + public init( + id: String, + uuid: String + ) { + self.id = id + self.uuid = uuid + } + } + + @Model + public class DeleteExternalResourceTaskModel { + @Attribute(.unique) public var id: String + public var uuid: String + public var relativePath: String + public var providerName: String + public var providerId: String + + public init( + id: String, + uuid: String, + relativePath: String, + providerName: String, + providerId: String + ) { + self.id = id + self.uuid = uuid + self.relativePath = relativePath + self.providerName = providerName + self.providerId = providerId + } + } +} diff --git a/Shared/SwiftData/Models/SyncJobType.swift b/Shared/SwiftData/Models/SyncJobType.swift index 7b935a993..60d9cd782 100644 --- a/Shared/SwiftData/Models/SyncJobType.swift +++ b/Shared/SwiftData/Models/SyncJobType.swift @@ -19,4 +19,25 @@ public enum SyncJobType: String, CaseIterable, Codable { case deleteBookmark case uploadArtwork case matchUuid + case externalResource + case externalResourceToDownload + case deleteExternalResource + /// Progress update pushed to an external provider (Jellyfin/AudiobookShelf/Hardcover) + case externalUpdate + /// File upload to the BookPlayer server storage + case uploadFile +} + +/// Queue keys for the tasks container. The `sync` queue serially runs the +/// BookPlayer-server jobs; every other key (provider names, `uploadFile`) +/// runs concurrently with the rest. +public enum TaskQueueKey { + public static let sync = "sync" + public static let uploadFile = "uploadFile" + + /// The lanes that talk to the BookPlayer server: gated on the sync entitlement, and held + /// by an account-level pause + public static func isServerLane(_ queueKey: String) -> Bool { + queueKey == sync || queueKey == uploadFile + } } diff --git a/Shared/SwiftData/Models/SyncTasksModels.swift b/Shared/SwiftData/Models/SyncTasksModels.swift index d19b80120..04e0a1245 100644 --- a/Shared/SwiftData/Models/SyncTasksModels.swift +++ b/Shared/SwiftData/Models/SyncTasksModels.swift @@ -9,15 +9,20 @@ import Foundation import SwiftData -public typealias SyncTasksContainer = SchemaV2.SyncTasksContainer -public typealias SyncTaskReferenceModel = SchemaV2.SyncTaskReferenceModel -public typealias UploadTaskModel = SchemaV2.UploadTaskModel -public typealias UpdateTaskModel = SchemaV2.UpdateTaskModel -public typealias MoveTaskModel = SchemaV2.MoveTaskModel -public typealias DeleteTaskModel = SchemaV2.DeleteTaskModel -public typealias DeleteBookmarkTaskModel = SchemaV2.DeleteBookmarkTaskModel -public typealias SetBookmarkTaskModel = SchemaV2.SetBookmarkTaskModel -public typealias RenameFolderTaskModel = SchemaV2.RenameFolderTaskModel -public typealias ArtworkUploadTaskModel = SchemaV2.ArtworkUploadTaskModel -public typealias MatchUuidsTaskModel = SchemaV2.MatchUuidsTaskModel +public typealias UploadTaskModel = SchemaV3.UploadTaskModel +public typealias UpdateTaskModel = SchemaV3.UpdateTaskModel +public typealias MoveTaskModel = SchemaV3.MoveTaskModel +public typealias DeleteTaskModel = SchemaV3.DeleteTaskModel +public typealias DeleteBookmarkTaskModel = SchemaV3.DeleteBookmarkTaskModel +public typealias SetBookmarkTaskModel = SchemaV3.SetBookmarkTaskModel +public typealias RenameFolderTaskModel = SchemaV3.RenameFolderTaskModel +public typealias ArtworkUploadTaskModel = SchemaV3.ArtworkUploadTaskModel +public typealias MatchUuidsTaskModel = SchemaV3.MatchUuidsTaskModel +public typealias UploadExternalResourceTaskModel = SchemaV3.UploadExternalResourceTaskModel +public typealias ExternalResourceToDownloadTaskModel = SchemaV3.ExternalResourceToDownloadTaskModel +public typealias DeleteExternalResourceTaskModel = SchemaV3.DeleteExternalResourceTaskModel +public typealias SyncQueueContainer = SchemaV3.SyncQueueContainer +public typealias QueuedTaskReferenceModel = SchemaV3.QueuedTaskReferenceModel +public typealias ExternalUpdateTaskModel = SchemaV3.ExternalUpdateTaskModel +public typealias UploadFileTaskModel = SchemaV3.UploadFileTaskModel diff --git a/Shared/SwiftData/SwiftDataModels+Extensions.swift b/Shared/SwiftData/SwiftDataModels+Extensions.swift index 9e48632bf..014812c0e 100644 --- a/Shared/SwiftData/SwiftDataModels+Extensions.swift +++ b/Shared/SwiftData/SwiftDataModels+Extensions.swift @@ -46,6 +46,12 @@ extension UploadTaskModel: DictionaryConvertible { dict["lastPlayDateTimestamp"] = lastPlayDateTimestamp } + /// Provider-backed items skip the device file upload (the server pulls the file + /// from the provider instead) — see `SyncQueueService.handleFinishedOperation` + if let provider { + dict["provider"] = provider + } + return dict } } @@ -175,3 +181,106 @@ extension MatchUuidsTaskModel: DictionaryConvertible { ] } } + +extension UploadExternalResourceTaskModel: DictionaryConvertible { + public func toDictionaryPayload() -> [String: Any] { + var dictionary: [String: Any] = [ + "id": id, + "uuid": uuid, + "providerId": providerId, + "providerName": providerName, + "processedFile": processedFile, + "syncStatus": syncStatus, + ] + // A raw Date is NOT a valid JSONSerialization object — including it crashes the upload + // with an uncatchable NSInvalidArgumentException. Epoch seconds travel instead; nil is + // simply omitted (the server owns the authoritative lastSyncedAt anyway). + if let lastSyncedAt { + dictionary["lastSyncedAt"] = lastSyncedAt.timeIntervalSince1970 + } + if let hostId { + dictionary["hostId"] = hostId + } + return dictionary + } +} + +extension ExternalResourceToDownloadTaskModel: DictionaryConvertible { + public func toDictionaryPayload() -> [String: Any] { + return [ + "id": id, + "uuid": uuid, + ] + } +} + +extension DeleteExternalResourceTaskModel: DictionaryConvertible { + public func toDictionaryPayload() -> [String: Any] { + return [ + "id": id, + "uuid": uuid, + "relativePath": relativePath, + "providerName": providerName, + "providerId": providerId + ] + } +} + +extension ExternalUpdateTaskModel: DictionaryConvertible { + public func toDictionaryPayload() -> [String: Any] { + var dictionary: [String: Any] = [ + "id": id, + "providerId": providerId, + "providerName": providerName + ] + + if let hostId { + dictionary["hostId"] = hostId + } + + if let title { + dictionary["title"] = title + } + + if let details { + dictionary["details"] = details + } + + if let currentTime { + dictionary["currentTime"] = currentTime + } + + if let percentCompleted { + dictionary["percentCompleted"] = percentCompleted + } + + if let isFinished { + dictionary["isFinished"] = isFinished + } + + if let lastPlayDateTimestamp { + dictionary["lastPlayDateTimestamp"] = lastPlayDateTimestamp + } + + return dictionary + } +} + +extension UploadFileTaskModel: DictionaryConvertible { + public func toDictionaryPayload() -> [String: Any] { + var dictionary: [String: Any] = [ + "id": id, + "uuid": uuid, + "filePath": filePath, + "partSize": partSize, + "fileSize": fileSize, + "restartCount": restartCount, + ] + + if let uploadId { + dictionary["uploadId"] = uploadId + } + + return dictionary + } +} diff --git a/Shared/SwiftData/TasksDataManager.swift b/Shared/SwiftData/TasksDataManager.swift index 99637860b..e9a1ea6dd 100644 --- a/Shared/SwiftData/TasksDataManager.swift +++ b/Shared/SwiftData/TasksDataManager.swift @@ -6,66 +6,131 @@ // Copyright © 2024 BookPlayer LLC. All rights reserved. // +import Combine +import CoreData import Foundation import SwiftData -import Combine -public final class TasksDataManager { +public final class TasksDataManager: BPLogger { public let container: ModelContainer - private let tasksCountSubject = CurrentValueSubject(0) - - public init() { - let schema = Schema([ - SyncTasksContainer.self, - SyncTaskReferenceModel.self, - UploadTaskModel.self, - UpdateTaskModel.self, - MoveTaskModel.self, - DeleteTaskModel.self, - DeleteBookmarkTaskModel.self, - SetBookmarkTaskModel.self, - RenameFolderTaskModel.self, - ArtworkUploadTaskModel.self, - MatchUuidsTaskModel.self - ]) - - let storeURL = DataManager.getSyncTasksSwiftDataURL() + private let queueCountsSubject = CurrentValueSubject(QueueCounts()) + + public convenience init() { + self.init(storeURL: DataManager.getSyncTasksSwiftDataURL()) + } + + /// Opens (or creates) the task store at `storeURL`, running `MigrationPlan` when needed. + /// + /// A store written by a schema the plan does not know can never be opened — dev builds + /// between schema edits, or an older build launched over a newer store (TestFlight + /// rollback). Left alone that is a permanent launch crash-loop, so it is set aside FIRST, + /// decided from the store's own metadata: SwiftData wraps the Cocoa error (134504/134100) + /// opaquely, so the code cannot be matched after the fact. Only that case is recoverable. + /// Anything else that fails to load — a bug in a custom migration stage, a corrupt file, a + /// fresh store that will not open — is a programming error and still crashes: the store is + /// healthy, and nuking it would silently discard queued tasks a code fix could still migrate. + init(storeURL: URL) { + let schema = Schema(versionedSchema: SchemaV3.self) let modelConfiguration = ModelConfiguration(url: storeURL, cloudKitDatabase: .none) - container = try! ModelContainer(for: schema, migrationPlan: MigrationPlan.self, configurations: [modelConfiguration]) + if Self.storeIsUnknownToMigrationPlan(at: storeURL) { + Self.logger.error("Sync-tasks store matches no schema in the migration plan; setting aside: \(storeURL.path)") + Self.setAsideStore(at: storeURL) + } + + do { + container = try ModelContainer(for: schema, migrationPlan: MigrationPlan.self, configurations: [modelConfiguration]) + } catch { + fatalError("Sync-tasks container failed to load: \(error)") + } // Initialize task count from database initializeTasksCount() } + /// `true` when a store exists at `storeURL` whose model matches none of `MigrationPlan.schemas` + /// — the same check SwiftData's staged migration makes before failing with "unknown model + /// version". A missing or unreadable store is NOT unknown: SwiftData gets to report it. + static func storeIsUnknownToMigrationPlan(at storeURL: URL) -> Bool { + guard + FileManager.default.fileExists(atPath: storeURL.path), + let metadata = try? NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: storeURL) + else { return false } + + return !MigrationPlan.schemas.contains { versionedSchema in + guard let model = NSManagedObjectModel.makeManagedObjectModel(for: versionedSchema.models) else { + return false + } + return model.isConfiguration(withName: nil, compatibleWithStoreMetadata: metadata) + } + } + + /// Move ASIDE, never delete: pending deletes and bookmarks in the store are not + /// re-derivable, and the copy survives for the eventual re-upgrade (or support recovery). + /// Only one generation is kept. + static func setAsideStore(at storeURL: URL) { + let fm = FileManager.default + for suffix in ["", "-wal", "-shm"] { + let src = URL(fileURLWithPath: storeURL.path + suffix) + let dst = URL(fileURLWithPath: storeURL.path + suffix + ".incompatible") + try? fm.removeItem(at: dst) + try? fm.moveItem(at: src, to: dst) + } + } + /// Test-only init that accepts a pre-built container (e.g. in-memory for unit tests). internal init(container: ModelContainer) { self.container = container initializeTasksCount() } - public func getTasksCount() -> Int { - tasksCountSubject.value - } + /// The latest snapshot, read now (thread-safe) + public var currentQueueCounts: QueueCounts { queueCountsSubject.value } - public func observeTasksCount() -> AnyPublisher { - return tasksCountSubject + /// Pending-task counts per queue key, delivered on main. Replays the latest snapshot on + /// subscribe, then emits after every store/pop. + public func observeQueueCounts() -> AnyPublisher { + return queueCountsSubject .receive(on: DispatchQueue.main) .eraseToAnyPublisher() } - + + /// Recount every lane from the unified container and publish the snapshot. public func notifyTasksChanged(context: ModelContext) { - let descriptor = FetchDescriptor() + let descriptor = FetchDescriptor() do { - let containers = try context.fetch(descriptor) - let count = containers.first?.tasks.count ?? 0 - tasksCountSubject.send(count) + let tasks = try context.fetch(descriptor).first?.tasks ?? [] + queueCountsSubject.send(Self.queueCounts(for: tasks)) } catch { - tasksCountSubject.send(0) + queueCountsSubject.send(QueueCounts()) } } - + + /// Same blocking rules as `SyncQueueRepository.getNextTask` + static func queueCounts(for tasks: [QueuedTaskReferenceModel]) -> QueueCounts { + let lanes = Dictionary(grouping: tasks, by: { $0.queueKey }) + let accountPaused = tasks.contains { $0.pauseScopeValue == .account } + var blocked = Set() + for (queueKey, laneTasks) in lanes { + let head = laneTasks + .sorted { $0.position < $1.position } + .first { $0.pauseScopeValue != .task } + if (accountPaused && TaskQueueKey.isServerLane(queueKey)) + || head?.pauseScopeValue == .lane + || head?.pauseScopeValue == .account { + blocked.insert(queueKey) + } + } + return QueueCounts( + byQueueKey: lanes.mapValues(\.count), + pausedByQueueKey: lanes + .mapValues { $0.filter { $0.pauseScope != nil }.count } + .filter { $0.value > 0 }, + blockedQueueKeys: blocked + ) + } + public func deleteAllTasks(with context: ModelContext) throws { // Task payload models are standalone (no relationships), so a store-level // batch delete is safe and fast. @@ -78,23 +143,31 @@ public final class TasksDataManager { try context.delete(model: RenameFolderTaskModel.self) try context.delete(model: ArtworkUploadTaskModel.self) try context.delete(model: MatchUuidsTaskModel.self) + try context.delete(model: UploadExternalResourceTaskModel.self) + try context.delete(model: ExternalResourceToDownloadTaskModel.self) + try context.delete(model: DeleteExternalResourceTaskModel.self) - // SyncTaskReferenceModel.container participates in a cascade relationship with - // SyncTasksContainer. A store-level batch delete runs below the object graph and - // skips relationship-maintenance (cascade/nullify) entirely, which trips a - // constraint-trigger / optimistic-lock error on that inverse. Delete through the + try context.delete(model: UploadFileTaskModel.self) + try context.delete(model: ExternalUpdateTaskModel.self) + + // QueuedTaskReferenceModel.container participates in a cascade relationship + // with SyncQueueContainer. A store-level batch delete runs below the object + // graph and skips relationship-maintenance (cascade/nullify) entirely, which trips + // a constraint-trigger / optimistic-lock error on that inverse. Delete through the // object graph instead: removing each container cascades to its task references. - let containers = try context.fetch(FetchDescriptor()) + let containers = try context.fetch(FetchDescriptor()) for container in containers { context.delete(container) } // Defensively clear any references that aren't attached to a container. - let orphanedReferences = try context.fetch(FetchDescriptor()) + let orphanedReferences = try context.fetch(FetchDescriptor()) for reference in orphanedReferences { context.delete(reference) } try context.save() + + notifyTasksChanged(context: context) } public func deleteTaskModel( @@ -173,21 +246,44 @@ public final class TasksDataManager { if let task = try context.fetch(descriptor).first { context.delete(task) } + case .externalResource: + let descriptor = FetchDescriptor( + predicate: #Predicate { task in task.id == id } + ) + if let task = try context.fetch(descriptor).first { + context.delete(task) + } + case .externalResourceToDownload: + let descriptor = FetchDescriptor( + predicate: #Predicate { task in task.id == id } + ) + if let task = try context.fetch(descriptor).first { + context.delete(task) + } + case .deleteExternalResource: + let descriptor = FetchDescriptor( + predicate: #Predicate { task in task.id == id } + ) + if let task = try context.fetch(descriptor).first { + context.delete(task) + } + case .externalUpdate: + let descriptor = FetchDescriptor( + predicate: #Predicate { task in task.id == id } + ) + if let task = try context.fetch(descriptor).first { + context.delete(task) + } + case .uploadFile: + let descriptor = FetchDescriptor( + predicate: #Predicate { task in task.id == id } + ) + if let task = try context.fetch(descriptor).first { + context.delete(task) + } } } - public func deleteReferenceModel( - with id: String, - jobType: SyncJobType, - context: ModelContext - ) throws { - let descriptor = FetchDescriptor( - predicate: #Predicate { task in task.taskID == id } - ) - if let task = try context.fetch(descriptor).first { - context.delete(task) - } - } // swiftlint:disable force_cast public func createTaskModel( @@ -197,24 +293,7 @@ public final class TasksDataManager { ) { switch jobType { case .upload: - let task = UploadTaskModel( - id: parameters["id"] as! String, - uuid: parameters["uuid"] as! String, - relativePath: parameters["relativePath"] as! String, - originalFileName: parameters["originalFileName"] as! String, - title: parameters["title"] as! String, - details: parameters["details"] as! String, - speed: parameters["speed"] as? Float, - currentTime: parameters["currentTime"] as! Double, - duration: parameters["duration"] as! Double, - percentCompleted: parameters["percentCompleted"] as! Double, - isFinished: parameters["isFinished"] as! Bool, - orderRank: parameters["orderRank"] as! Int, - lastPlayDateTimestamp: parameters["lastPlayDateTimestamp"] as? Double, - type: parameters["type"] as! Int16, - ) - context.insert(task) - + context.insert(buildUploadTask(parameters)) case .update: let task = UpdateTaskModel( id: parameters["id"] as! String, @@ -293,9 +372,88 @@ public final class TasksDataManager { uuids: parameters["uuids"] as! [String: String] ) context.insert(task) + case .externalResource: + context.insert(buildUploadExternalResourceTask(parameters)) + case .externalResourceToDownload: + let task = ExternalResourceToDownloadTaskModel( + id: parameters["id"] as! String, + // The producer types uuid as String? and only inserts it `if let` — match + // storeTask's defensive read instead of trapping on a persisted absence + uuid: parameters["uuid"] as? String ?? "" + ) + context.insert(task) + case .deleteExternalResource: + context.insert(buildDeleteExternalResourceTask(parameters)) + case .externalUpdate: + context.insert(buildExternalUpdateTask(parameters)) + case .uploadFile: + let task = UploadFileTaskModel( + id: parameters["id"] as! String, + uuid: parameters["uuid"] as! String, + filePath: parameters["filePath"] as! String + ) + context.insert(task) } } + private func buildExternalUpdateTask(_ parameters: [String: Any]) -> ExternalUpdateTaskModel { + return ExternalUpdateTaskModel( + id: parameters["id"] as! String, + providerName: parameters["providerName"] as! String, + providerId: parameters["providerId"] as! String, + title: parameters["title"] as? String, + details: parameters["details"] as? String, + currentTime: parameters["currentTime"] as? Double, + percentCompleted: parameters["percentCompleted"] as? Double, + isFinished: parameters["isFinished"] as? Bool, + lastPlayDateTimestamp: parameters["lastPlayDateTimestamp"] as? Double, + hostId: parameters["hostId"] as? String, + ) + } + + private func buildUploadExternalResourceTask(_ parameters: [String: Any]) -> UploadExternalResourceTaskModel { + return UploadExternalResourceTaskModel( + id: parameters["id"] as! String, + uuid: parameters["uuid"] as! String, + providerId: parameters["providerId"] as! String, + providerName: parameters["providerName"] as! String, + lastSyncedAt: parameters["lastSyncedAt"] as? Date, + syncStatus: parameters["syncStatus"] as! String, + processedFile: parameters["processedFile"] as! Bool, + hostId: parameters["hostId"] as? String + ) + } + + private func buildDeleteExternalResourceTask(_ parameters: [String: Any]) -> DeleteExternalResourceTaskModel { + return DeleteExternalResourceTaskModel( + id: parameters["id"] as! String, + uuid: parameters["uuid"] as! String, + relativePath: parameters["relativePath"] as! String, + providerName: parameters["providerName"] as! String, + providerId: parameters["providerId"] as! String + ) + } + + private func buildUploadTask(_ parameters: [String: Any]) -> UploadTaskModel { + return UploadTaskModel( + id: parameters["id"] as! String, + uuid: parameters["uuid"] as! String, + relativePath: parameters["relativePath"] as! String, + originalFileName: parameters["originalFileName"] as! String, + title: parameters["title"] as! String, + details: parameters["details"] as! String, + speed: parameters["speed"] as? Float, + currentTime: parameters["currentTime"] as! Double, + duration: parameters["duration"] as! Double, + percentCompleted: parameters["percentCompleted"] as! Double, + isFinished: parameters["isFinished"] as! Bool, + orderRank: parameters["orderRank"] as! Int, + lastPlayDateTimestamp: parameters["lastPlayDateTimestamp"] as? Double, + type: parameters["type"] as! Int16, + provider: parameters["provider"] as? String + ) + } + // swiftlint:enable force_cast public func getTaskModel( @@ -357,6 +515,31 @@ public final class TasksDataManager { predicate: #Predicate { task in task.id == id } ) return try context.fetch(descriptor).first + case .externalResource: + let descriptor = FetchDescriptor( + predicate: #Predicate { task in task.id == id } + ) + return try context.fetch(descriptor).first + case .externalResourceToDownload: + let descriptor = FetchDescriptor( + predicate: #Predicate { task in task.id == id } + ) + return try context.fetch(descriptor).first + case .deleteExternalResource: + let descriptor = FetchDescriptor( + predicate: #Predicate { task in task.id == id } + ) + return try context.fetch(descriptor).first + case .externalUpdate: + let descriptor = FetchDescriptor( + predicate: #Predicate { task in task.id == id } + ) + return try context.fetch(descriptor).first + case .uploadFile: + let descriptor = FetchDescriptor( + predicate: #Predicate { task in task.id == id } + ) + return try context.fetch(descriptor).first } } catch { return nil @@ -366,7 +549,10 @@ public final class TasksDataManager { public func updateTaskModel(_ task: UpdateTaskModel, with parameters: [String: Any]) { if let title = parameters["title"] as? String { task.title = title } if let details = parameters["details"] as? String { task.details = details } - if let speed = parameters["speed"] as? Double { task.speed = speed } + // The scheduler boxes a Swift Float here (LibraryItem.speed) and an Any-boxed Float + // fails `as? Double` — which silently dropped every COALESCED speed change (the + // create paths read `as? Float` and were fine). NSNumber bridging accepts both. + if let speed = parameters["speed"] as? NSNumber { task.speed = speed.doubleValue } if let currentTime = parameters["currentTime"] as? Double { task.currentTime = currentTime } if let duration = parameters["duration"] as? Double { task.duration = duration } if let percentCompleted = parameters["percentCompleted"] as? Double { task.percentCompleted = percentCompleted } @@ -378,18 +564,25 @@ public final class TasksDataManager { if let type = parameters["type"] as? Int16 { task.type = type } } - /// Initialize the tasks count from the database on startup + /// Initialize both task counts from the database on startup private func initializeTasksCount() { let context = ModelContext(container) - - do { - let descriptor = FetchDescriptor() - let containers = try context.fetch(descriptor) - let count = containers.first?.tasks.count ?? 0 - tasksCountSubject.send(count) - } catch { - // If there's an error reading from the database, keep the default value of 0 - tasksCountSubject.send(0) + notifyTasksChanged(context: context) + } + + public func updateExternalUpdateTaskModel( + for task: ExternalUpdateTaskModel, + with parameters: [String: Any], + in context: ModelContext + ) { + if let title = parameters["title"] as? String { task.title = title } + if let details = parameters["details"] as? String { task.details = details } + if let currentTime = parameters["currentTime"] as? Double { task.currentTime = currentTime } + if let percentCompleted = parameters["percentCompleted"] as? Double { task.percentCompleted = percentCompleted } + if let isFinished = parameters["isFinished"] as? Bool { task.isFinished = isFinished } + if let lastPlayDateTimestamp = parameters["lastPlayDateTimestamp"] as? Double { + task.lastPlayDateTimestamp = lastPlayDateTimestamp } + if let hostId = parameters["hostId"] as? String { task.hostId = hostId } } }