From 04dfc3f979d898a43081c63a87d909a81aeda506 Mon Sep 17 00:00:00 2001 From: Alexey Date: Sun, 12 May 2024 21:32:48 +0300 Subject: [PATCH] Initial commit --- .github/workflows/callable.build.yml | 26 + .github/workflows/callable.gradle-release.yml | 81 + .../workflows/callable.publish-javadoc.yml | 152 + .../workflows/callable.publish-sonatype.yml | 34 + .github/workflows/ci.yml | 66 + .gitignore | 43 + LICENSE.md | 21 + README.md | 350 + gradle.properties | 1 + gradle/libs.versions.toml | 20 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 60756 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 234 + gradlew.bat | 89 + lib/build.gradle.kts | 86 + .../io/github/thoroldvix/api/Transcript.java | 79 + .../thoroldvix/api/TranscriptContent.java | 62 + .../thoroldvix/api/TranscriptFormatter.java | 16 + .../thoroldvix/api/TranscriptFormatters.java | 121 + .../github/thoroldvix/api/TranscriptList.java | 63 + .../api/TranscriptRetrievalException.java | 47 + .../github/thoroldvix/api/YoutubeClient.java | 22 + .../thoroldvix/api/YoutubeTranscriptApi.java | 97 + .../internal/DefaultTranscript.java | 144 + .../internal/DefaultTranscriptContent.java | 112 + .../internal/DefaultTranscriptList.java | 123 + .../internal/DefaultYoutubeClient.java | 69 + .../internal/DefaultYoutubeTranscriptApi.java | 145 + .../thoroldvix/internal/FileLinesReader.java | 19 + .../internal/TranscriptApiFactory.java | 35 + .../internal/TranscriptContentXML.java | 68 + .../internal/TranscriptListJSON.java | 132 + .../TranscriptRetrievalExceptionTest.java | 20 + .../DefaultTranscriptContentTest.java | 33 + .../internal/DefaultTranscriptListTest.java | 184 + .../internal/DefaultTranscriptTest.java | 120 + .../internal/DefaultYoutubeClientTest.java | 94 + .../DefaultYoutubeTranscriptApiTest.java | 239 + .../internal/TranscriptFormattersTest.java | 163 + lib/src/test/resources/example_cookies.txt | 5 + .../test/resources/pages/youtube.html.static | 4215 +++++ .../pages/youtube_consent_page.html.static | 160 + .../youtube_consent_page_invalid.html.static | 332 + ...outube_malformed_captions_json.html.static | 4215 +++++ ...outube_no_transcript_available.html.static | 3014 ++++ .../pages/youtube_no_translation.html.static | 14562 ++++++++++++++++ .../youtube_too_many_requests.html.static | 251 + .../youtube_transcripts_disabled.html.static | 3242 ++++ .../youtube_transcripts_disabled2.html.static | 10846 ++++++++++++ .../youtube_video_unavailable.html.static | 1325 ++ lib/src/test/resources/transcript.xml | 7 + renovate.json | 9 + settings.gradle.kts | 2 + 53 files changed, 45601 insertions(+) create mode 100644 .github/workflows/callable.build.yml create mode 100644 .github/workflows/callable.gradle-release.yml create mode 100644 .github/workflows/callable.publish-javadoc.yml create mode 100644 .github/workflows/callable.publish-sonatype.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 lib/build.gradle.kts create mode 100644 lib/src/main/java/io/github/thoroldvix/api/Transcript.java create mode 100644 lib/src/main/java/io/github/thoroldvix/api/TranscriptContent.java create mode 100644 lib/src/main/java/io/github/thoroldvix/api/TranscriptFormatter.java create mode 100644 lib/src/main/java/io/github/thoroldvix/api/TranscriptFormatters.java create mode 100644 lib/src/main/java/io/github/thoroldvix/api/TranscriptList.java create mode 100644 lib/src/main/java/io/github/thoroldvix/api/TranscriptRetrievalException.java create mode 100644 lib/src/main/java/io/github/thoroldvix/api/YoutubeClient.java create mode 100644 lib/src/main/java/io/github/thoroldvix/api/YoutubeTranscriptApi.java create mode 100644 lib/src/main/java/io/github/thoroldvix/internal/DefaultTranscript.java create mode 100644 lib/src/main/java/io/github/thoroldvix/internal/DefaultTranscriptContent.java create mode 100644 lib/src/main/java/io/github/thoroldvix/internal/DefaultTranscriptList.java create mode 100644 lib/src/main/java/io/github/thoroldvix/internal/DefaultYoutubeClient.java create mode 100644 lib/src/main/java/io/github/thoroldvix/internal/DefaultYoutubeTranscriptApi.java create mode 100644 lib/src/main/java/io/github/thoroldvix/internal/FileLinesReader.java create mode 100644 lib/src/main/java/io/github/thoroldvix/internal/TranscriptApiFactory.java create mode 100644 lib/src/main/java/io/github/thoroldvix/internal/TranscriptContentXML.java create mode 100644 lib/src/main/java/io/github/thoroldvix/internal/TranscriptListJSON.java create mode 100644 lib/src/test/java/io/github/thoroldvix/TranscriptRetrievalExceptionTest.java create mode 100644 lib/src/test/java/io/github/thoroldvix/internal/DefaultTranscriptContentTest.java create mode 100644 lib/src/test/java/io/github/thoroldvix/internal/DefaultTranscriptListTest.java create mode 100644 lib/src/test/java/io/github/thoroldvix/internal/DefaultTranscriptTest.java create mode 100644 lib/src/test/java/io/github/thoroldvix/internal/DefaultYoutubeClientTest.java create mode 100644 lib/src/test/java/io/github/thoroldvix/internal/DefaultYoutubeTranscriptApiTest.java create mode 100644 lib/src/test/java/io/github/thoroldvix/internal/TranscriptFormattersTest.java create mode 100644 lib/src/test/resources/example_cookies.txt create mode 100644 lib/src/test/resources/pages/youtube.html.static create mode 100644 lib/src/test/resources/pages/youtube_consent_page.html.static create mode 100644 lib/src/test/resources/pages/youtube_consent_page_invalid.html.static create mode 100644 lib/src/test/resources/pages/youtube_malformed_captions_json.html.static create mode 100644 lib/src/test/resources/pages/youtube_no_transcript_available.html.static create mode 100644 lib/src/test/resources/pages/youtube_no_translation.html.static create mode 100644 lib/src/test/resources/pages/youtube_too_many_requests.html.static create mode 100644 lib/src/test/resources/pages/youtube_transcripts_disabled.html.static create mode 100644 lib/src/test/resources/pages/youtube_transcripts_disabled2.html.static create mode 100644 lib/src/test/resources/pages/youtube_video_unavailable.html.static create mode 100644 lib/src/test/resources/transcript.xml create mode 100644 renovate.json create mode 100644 settings.gradle.kts diff --git a/.github/workflows/callable.build.yml b/.github/workflows/callable.build.yml new file mode 100644 index 0000000..08c92a2 --- /dev/null +++ b/.github/workflows/callable.build.yml @@ -0,0 +1,26 @@ +name: Build & Test + +on: workflow_call + +jobs: + build: + name: gradle build + runs-on: ubuntu-latest + steps: + - name: Checkout project sources + uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '21' + + - uses: gradle/actions/wrapper-validation@v3 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3.3.2 + with: + cache-write-only: true + + - name: Build with Gradle + run: ./gradlew build --no-daemon \ No newline at end of file diff --git a/.github/workflows/callable.gradle-release.yml b/.github/workflows/callable.gradle-release.yml new file mode 100644 index 0000000..55bbb9c --- /dev/null +++ b/.github/workflows/callable.gradle-release.yml @@ -0,0 +1,81 @@ +name: Gradle Release + +on: + workflow_call: + inputs: + type: + description: 'Release type' + required: true + type: string + +jobs: + release: + name: gradle release + runs-on: ubuntu-latest + steps: + - name: Validate 'Release Type' param + env: + TYPE: ${{ inputs.type }} + run: | + valid_types=(major minor patch) + if [[ ! ${valid_types[*]} =~ "$TYPE" ]]; then + echo "Unknown release type: $TYPE" + exit 1 + fi + + - name: Checkout project sources ('main' branch) + uses: actions/checkout@v4 + with: + ref: main + token: ${{ secrets.CI_GITHUB_TOKEN }} + - uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '21' + + - uses: gradle/actions/wrapper-validation@v3 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3.3.2 + with: + cache-read-only: true + + - name: Get current version + run: | + source gradle.properties + echo "current_version=${version}" >> $GITHUB_ENV + + - name: Determine version type + env: + TYPE: ${{ inputs.type }} + VERSION: ${{ env.current_version }} + run: | + export major=$(echo "${VERSION}" | cut -d. -f1) + export minor=$(echo "${VERSION}" | cut -d. -f2) + export patch=$(echo "${VERSION}" | cut -d. -f3 | cut -d- -f1) + echo "resolved: ${major}.${minor}.${patch}" + + if [[ "$TYPE" == "major" ]]; then + echo "new_version=$((major+1)).0.0" >> $GITHUB_ENV + echo "new_snapshot_version=$((major+1)).0.1-SNAPSHOT" >> $GITHUB_ENV + elif [ "$TYPE" == "minor" ]; then + echo "new_version=${major}.$((minor+1)).0" >> $GITHUB_ENV + echo "new_snapshot_version=${major}.$((minor+1)).1-SNAPSHOT" >> $GITHUB_ENV + else + echo "new_version=${major}.${minor}.${patch}" >> $GITHUB_ENV + echo "new_snapshot_version=${major}.${minor}.$((patch+1))-SNAPSHOT" >> $GITHUB_ENV + fi + + - name: Set git config 'user.name' and 'user.email' + run: | + git config --local user.email "action@github.com" + git config --local user.name "github-actions[bot]" + + - name: Run 'gradle release' + run: | + echo "Type: ${{ inputs.type }}" + echo "Current version: ${{ env.current_version }}" + echo "New version: ${{ env.new_version }}" + echo "New snapshot version: ${{ env.new_snapshot_version }}" + echo "./gradlew release -Prelease.useAutomaticVersion=true -Prelease.releaseVersion=${{ env.new_version }} -Prelease.newVersion=${{ env.new_snapshot_version }}" + gradle release -Prelease.useAutomaticVersion=true -Prelease.releaseVersion=${{ env.new_version }} -Prelease.newVersion=${{ env.new_snapshot_version }} \ No newline at end of file diff --git a/.github/workflows/callable.publish-javadoc.yml b/.github/workflows/callable.publish-javadoc.yml new file mode 100644 index 0000000..0c53375 --- /dev/null +++ b/.github/workflows/callable.publish-javadoc.yml @@ -0,0 +1,152 @@ +name: Publish javadoc (GitHub Pages) + +on: + workflow_dispatch: + workflow_call: + +jobs: + build_package_javadoc: + name: Generate Javadoc + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout project sources + uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '21' + + - uses: gradle/actions/wrapper-validation@v3 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3.3.2 + with: + cache-read-only: true + + - name: Generate javadoc (gradle) + run: ./gradlew javadoc + + - name: Conclude javadoc version and set env + run: | + if [[ "$GITHUB_REF" == "refs/heads/main" || "$GITHUB_REF" == "refs/heads/master" ]]; then + echo "PUBLISH_VERSION=current" >> $GITHUB_ENV + else + echo "PUBLISH_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV + fi + + - name: zip javadoc folder + env: + LIBRARY_NAME: ${{ env.LIBRARY_NAME }} + run: | + cd "lib/build/docs/javadoc" + zip -r ../../../../javadoc.zip . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: javadoc.zip + path: javadoc.zip + + deploy_javadoc: + name: Deploy (GH Pages) + runs-on: ubuntu-latest + needs: build_package_javadoc + permissions: + contents: write + steps: + - name: Checkout project sources + uses: actions/checkout@v4 + with: + ref: main + token: ${{ secrets.CI_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Checkout or create empty branch 'gh-pages' + run: | + git fetch origin gh-pages || true + git checkout gh-pages || git switch --orphan gh-pages + + - name: Conclude javadoc version and set env + run: | + if [[ "$GITHUB_REF" == "refs/heads/main" || "$GITHUB_REF" == "refs/heads/master" ]]; then + echo "PUBLISH_VERSION=current" >> $GITHUB_ENV + else + echo "PUBLISH_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV + fi + + - name: Create root index redirect + env: + GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} + run: | + echo "

/$GITHUB_REPOSITORY_NAME/javadoc/

" > index.html + + - name: Download artifact from build job + uses: actions/download-artifact@v4 + with: + name: javadoc.zip + + - name: unzip javadoc folder + env: + PUBLISH_VERSION: ${{ env.PUBLISH_VERSION }} + run: | + mkdir -p javadoc + rm -Rf "javadoc/$PUBLISH_VERSION" || true + unzip -d "javadoc/$PUBLISH_VERSION" javadoc.zip + rm javadoc.zip + + - name: Create javadoc index.html listing versions + env: + PUBLISH_VERSION: ${{ env.PUBLISH_VERSION }} + GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} + run: | + mkdir -p javadoc + rm javadoc/index.html || true + touch javadoc/index.html + + versions=( $(cd javadoc && find . -maxdepth 1 -type d | jq -srR 'split("\n") | unique | .[][2:] | select(length > 0)') ) + + echo "javadoc versions:" + for value in "${versions[@]}" + do + echo "- $value" + done + + echo "" >> javadoc/index.html + echo "" >> javadoc/index.html + echo "" >> javadoc/index.html + echo " Javadoc | '$GITHUB_REPOSITORY_NAME'" >> javadoc/index.html + echo " " >> javadoc/index.html + echo " " >> javadoc/index.html + echo " " >> javadoc/index.html + echo " " >> javadoc/index.html + echo " " >> javadoc/index.html + echo "" >> javadoc/index.html + echo "" >> javadoc/index.html + echo "
" >> javadoc/index.html + echo "

Javadoc

" >> javadoc/index.html + echo "

Versions

" >> javadoc/index.html + echo " " >> javadoc/index.html + echo "
" >> javadoc/index.html + echo "" >> javadoc/index.html + echo "" >> javadoc/index.html + + - name: Commit files + run: | + git config --local user.email "action@github.com" + git config --local user.name "github-actions[bot]" + git add . + git status + git diff-index --quiet HEAD || git commit -m "chore: update index.html files incl. javadoc versions" + + # Push changes + - name: Push changes + run: | + git push --set-upstream origin gh-pages \ No newline at end of file diff --git a/.github/workflows/callable.publish-sonatype.yml b/.github/workflows/callable.publish-sonatype.yml new file mode 100644 index 0000000..0a5e82e --- /dev/null +++ b/.github/workflows/callable.publish-sonatype.yml @@ -0,0 +1,34 @@ +name: Publish to Sonatype (Maven Central) + +on: + workflow_call: + +jobs: + publish: + name: Publish to Sonatype (Maven Central) + runs-on: ubuntu-latest + steps: + - name: Checkout project sources + uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '21' + cache: 'gradle' + + - uses: gradle/actions/wrapper-validation@v3 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3.3.2 + with: + cache-read-only: true + + - name: Publish to Sonatype (Maven Central) + if: github.ref_type == 'tag' + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} + run: ./gradlew publishMavenPublicationToMavenCentralRepository diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f5d5802 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: [ "main" ] + tags: + - '*' + pull_request: + branches: [ '*' ] + workflow_dispatch: + inputs: + type: + description: 'Release Library' + required: true + default: '...no release' + type: choice + options: + - '...no release' + - major + - minor + - patch + +jobs: + build: + name: Build + uses: ./.github/workflows/callable.build.yml + if: | # avoid unnecessary pipeline runs during artifact release process ('gradle release plugin') + !contains(github.event.head_commit.message, 'chore: bump current version to') + || github.ref_type == 'tag' + + gradle-release: + name: Create Release + uses: ./.github/workflows/callable.gradle-release.yml + secrets: inherit + with: + type: ${{ inputs.type }} + needs: build + if: | + github.event_name == 'workflow_dispatch' + && inputs.type != '...no release' + + publish_sonatype: + name: Publish artifact (Maven Central) + uses: ./.github/workflows/callable.publish-sonatype.yml + secrets: inherit + needs: build + if: | + ( + github.event_name != 'workflow_dispatch' + || inputs.type == '...no release' + ) && ( + github.ref == 'refs/heads/main' + || github.ref_type == 'tag' + ) + + publish_javadoc: + name: Publish Javadoc to GitHub Pages + permissions: + contents: write + uses: ./.github/workflows/callable.publish-javadoc.yml + needs: build + if: | + ( + github.ref == 'refs/heads/main' + && ( inputs.type == '' || inputs.type == '...no release' ) + ) || github.ref_type == 'tag' \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26bee3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +/.idea/ +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..0d4f52a --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Alexey Bobkov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d19695b --- /dev/null +++ b/README.md @@ -0,0 +1,350 @@ +# 📝 YouTube Transcript API + +![Java CI](https://github.com/thoroldvix/youtube-transcript-api/actions/workflows/ci.yml/badge.svg) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.thoroldvix/youtube-transcript-api)](https://search.maven.org/artifact/io.github.thoroldvix/youtube-transcript-api) +[![Javadoc](https://img.shields.io/badge/JavaDoc-Online-green)](https://thoroldvix.github.io/youtube-transcript-api/javadoc/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## ⚠️WARNING ⚠️ + +### This library uses undocumented YouTube API, so it's possible that it will stop working at any time. Use at your own risk. + +## 💡Quick start + +For a quick getting started, see [documentation](https://thoroldvix.github.io/youtube-transcript-api/javadoc/). + +## 📖 Introduction + +Java library which allows you to retrieve subtitles/transcripts for a YouTube video. +It supports manual and automatically generated subtitles and does not use headless browser for scraping. +Inspired by [Python library](https://github.com/jdepoix/youtube-transcript-api). + +## 🤖 Features + +✅ Manual transcripts retrieval + +✅ Automatically generated transcripts retrieval + +✅ Transcript translation + +✅ Transcript formatting + +✅ Easy-to-use API + +✅ Minimal dependencies (Jackson for XML/JSON processing and Apache Commons Text for XML tag unescaping) + +✅ Supports Java 11 and above + +## 🛠️ Installation + +### Maven + +```xml + + + io.github.thoroldvix + youtube-transcript-api + 0.1.0 + +``` + +### Gradle + +```groovy +implementation 'io.github.thoroldvix:youtube-transcript-api:0.1.0' +``` + +### Gradle (kts) + +```kotlin +implementation("io.github.thoroldvix:youtube-transcript-api:0.1.0") +``` + +## 🔰 Getting Started + +To start using YouTube Transcript API, you need to create an instance of `YoutubeTranscriptApi` by +calling `createDefault` +method of `TranscriptApiFactory`. Then you can call `listTranscripts` to get a list of all available transcripts for a +video: + +```java +// Create a new default YoutubeTranscriptApi instance +YoutubeTranscriptApi youtubeTranscriptApi = TranscriptApiFactory.createDefault(); + +// Retrieve all available transcripts for a given video +TranscriptList transcriptList = youtubeTranscriptApi.listTranscripts("videoId"); +``` + +`TranscripList` is an iterable which contains all available transcripts for a video and provides methods +for [finding specific transcripts](#find-transcripts) by language or by type (manual or automatically generated). + +```java +TranscriptList transcriptList = youtubeTranscriptApi.listTranscripts("videoId"); + +// Iterate over transcript list +for(Transcript transcript : transcriptList) { + System.out.println(transcript); +} + +// Find transcript in specific language +Transcript transcript = transcriptList.findTranscript("en"); + +// Find manually created transcript +Transcript manualyCreatedTranscript = transcriptList.findManualTranscript("en"); + +// Find automatically generated transcript +Transcript automaticallyGeneratedTranscript = transcriptList.findGeneratedTranscript("en"); +``` + +`Transcript` object contains [transcript metadata](#transcript-metadata) and provides methods for translating the +transcript to another language +and fetching the actual content of the transcript. + +```java +Transcript transcript = transcriptList.findTranscript("en"); + +// Translate transcript to another language +Transcript translatedTranscript = transcript.translate("de"); + +// Retrieve transcript content +TranscriptContent transcriptContent = transcript.fetch(); +``` + +`TranscriptContent` contains actual transcript content, storing it as a list of `Fragment`. +Each `Fragment` contains 'text', 'start' and 'duration' +attributes. If you try to print the `TranscriptContent`, you will get the output looking like this: + +```text +content=[{text='Text',start=0.0,dur=1.54},{text='Another text',start=1.54,dur=4.16}] +``` + +> **Note:** If you want to get transcript content in a different format, refer +> to [Use Formatters](#use-formatters). + +You can also use `getTranscript`: + +```java +TranscriptContent transcriptContent = youtubeTranscriptApi.getTranscript("videoId", "en"); +``` + +This is equivalent to: + +```java +TranscriptContent transcriptContent = youtubeTranscriptApi.listTranscripts("videoId") + .findTranscript("en") + .fetch(); +``` + +Given that English is the most common language, you can omit the language code, and it will default to English: + +```java +// Retrieve transcript content in english +TranscriptContent transcriptContent = youtubeTranscriptApi.listTranscripts("videoId") + //no language code defaults to english + .findTranscript() + .fetch(); +// Or +TranscriptContent transcriptContent = youtubeTranscriptApi.getTranscript("videoId"); +``` + +## 🔧 Detailed Usage + +### Use fallback language + +In case if desired language is not available, instead of getting an exception you can pass some other languages that +will be used as a fallback. + +For example: + +```java +TranscriptContent transcriptContent = youtubeTranscriptApi.listTranscripts("videoId") + .findTranscript("de", "en") + .fetch(); + +// Or +TranscriptContent transcriptContent = youtubeTranscriptApi.getTranscript("videoId", "de", "en"); +``` + +It will first look for a transcript in German, and if it doesn't find one, it will then look for one in English, and so +on. + +### Find transcripts + +By default, `findTranscript` will always pick manually created transcripts first and then automatically generated ones. +If you want to get only automatically generated or only manually created transcripts, you can use `findManualTranscript` +or `findGeneratedTranscript`. + +```java +// Retrieve manually created transcript +Transcript manualyCreatedTranscript = transcriptList.findManualTranscript("en"); + +// Retrieve automatically generated transcript +Transcript automaticallyGeneratedTranscript = transcriptList.findGeneratedTranscript("en"); +``` + +`findGeneratedTranscript` and `findManualTranscript` both +support [fallback languages](#use-fallback-language). + +### Transcript metadata + +`Transcript` object contains several methods for retrieving transcript metadata: + +```java +String videoId = transcript.getVideoId(); + +String language = transcript.getLanguage(); + +String languageCode = transcript.getLanguageCode(); + +// API URL used to fetch transcript content +String apiUrl = transcript.getApiUrl(); + +// Whether it has been manually created or automatically generated by YouTube +boolean isGenerated = transcript.isGenerated(); + +// Whether this transcript can be translated or not +boolean isTranslatable = transcript.isTranslatable(); + +// Set of language codes which represent available translation languages +Set translationLanguages = transcript.getTranslationLanguages(); +``` + +### Use Formatters + +By default, if you try to print `TranscriptContent` it will return the following string representation: + +```text +content=[{text='Text',start=0.0,dur=1.54},{text='Another text',start=1.54,dur=4.16}] +``` + +Since this default format may not be suitable for all scenarios, you can implement the `TranscriptFormatter` interface +to customize the formatting of the content. + +```java +// Create a new custom formatter +Formatter transcriptFormatter = new MyCustomFormatter(); + +// Format transcript content +String formattedContent = transcriptFormatter.format(transcriptContent); +``` + +The library offers several built-in formatters: + +- `JSONFormatter` - Formats content as JSON +- `JSONPrettyFormatter` - Formats content as pretty-printed JSON +- `TextFormatter` - Formats content as plain text without timestamps +- `WebVTTFormatter` - Formats content as [WebVTT](https://developer.mozilla.org/en-US/docs/Web/API/WebVTT_API) +- `SRTFormatter` - Formats content as [SRT](https://www.3playmedia.com/blog/create-srt-file/) + +These formatters can be accessed from the `TranscriptFormatters` class: + +```java +// Get json formatter +TranscriptFormatter jsonFormatter = TranscriptFormatters.jsonFormatter(); + +String formattedContent = jsonFormatter.format(transcriptContent); +```` + +### YoutubeClient customization + +By default, `YoutubeTranscriptApi` uses Java 11 HttpClient for making requests to YouTube, if you want to use a +different client, +you can create your own YouTube client by implementing the `YoutubeClient` interface and passing it to +the `YoutubeTranscriptApiFactory` `createWithClient` method. + +```java +// Create a new custom YoutubeClient +YoutubeClient youtubeClient = new MyCustomYoutubeClient(); + +// Create YoutubeTranscriptApi instance with custom YouTubeClient +YoutubeTranscriptApi youtubeTranscriptApi = TranscriptApiFactory.createWithClient(youtubeClient); +``` + +### Cookies + +Some videos may be age-restricted, requiring authentication to access the transcript. +To achieve this, obtain access to the desired video in a browser and download the cookies in Netscape format, storing +them as a TXT file. +You can use extensions +like [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc) +for Chrome or [cookies.txt](https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/) for Firefox to do this. +`YoutubeTranscriptApi` contains `listTranscriptsWithCookies` and `getTranscriptWithCookies` which accept a path to the +cookies.txt file. + +```java +// Retrieve transcript list +TranscriptList transcriptList = youtubeTranscriptApi.listTranscriptsWithCookies("videoId", "path/to/cookies.txt"); + +// Get transcript content +TranscriptContent transcriptContent = youtubeTranscriptApi.getTranscriptWithCookies("videoId", "path/to/cookies.txt", "en"); +``` + +## 🤓 How it works + +Within each YouTube video page, there exists JSON data containing all the transcript information, including an +undocumented API URL embedded within its HTML. This JSON looks like this: + +```json +{ + "captions": { + "playerCaptionsTracklistRenderer": { + "captionTracks": [ + { + "baseUrl": "https://www.youtube.com/api/timedtext?v=dQw4w9WgXcQ&asr_langs=de,en,es,fr,it,ja,ko,nl,pt,ru&caps=asr&xorp=true&hl=de&ip=0.0.0.0&ipbits=0&expire=1570645639&sparams=ip,ipbits,expire,v,asr_langs,caps,xorp&signature=5939E534881E9A14C14BCEDF370DE7A4E5FD4BE0.01ABE3BA9B2BCDEC6C51D6A9D9F898460495F0F2&key=yt8&lang=de", + "name": { + "simpleText": "Deutsch" + }, + "vssId": ".de", + "languageCode": "de", + "isTranslatable": true + }, + { + "baseUrl": "https://www.youtube.com/api/timedtext?v=dQw4w9WgXcQ&asr_langs=de,en,es,fr,it,ja,ko,nl,pt,ru&caps=asr&xorp=true&hl=de&ip=0.0.0.0&ipbits=0&expire=1570645639&sparams=ip,ipbits,expire,v,asr_langs,caps,xorp&signature=5939E534881E9A14C14BCEDF370DE7A4E5FD4BE0.01ABE3BA9B2BCDEC6C51D6A9D9F898460495F0F2&key=yt8&lang=en", + "name": { + "simpleText": "Englisch" + }, + "vssId": ".en", + "languageCode": "en", + "kind": "asr", + "isTranslatable": true + } + ], + "translationLanguages": [ + { + "languageCode": "af", + "languageName": { + "simpleText": "Afrikaans" + } + } + ] + } + } +} +``` + +This library works by making a single GET request to the YouTube page of the specified video, extracting the JSON data +from the HTML, and parsing it to obtain a list of all available transcripts. To fetch the transcript content, it then +sends a GET request to the API URL extracted from the JSON. The YouTube API returns the transcript content in XML +format, like this: + +```xml + + + Some text + Some additional text + +``` + +## 📖 License + +This library is licensed under the MIT License. See +the [LICENSE](https://github.com/dignifiedquire/youtube-transcript-api/blob/master/LICENSE) file for more information. + + + + + + + + + diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..583ef68 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +version=0.0.1-SNAPSHOT \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..66e2568 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,20 @@ +[versions] +junit = "5.10.2" +assertj = "3.25.3" +mockito = "5.12.0" +jackson = "2.17.1" +apache-commons-text = "1.12.0" +maven-publish = "0.28.0" +gradle-release = "3.0.2" + +[libraries] +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } +junit-jupiter-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertj" } +mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" } +jackson-dataformat-xml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-xml", version.ref = "jackson" } +apache-commons-text = { module = "org.apache.commons:commons-text", version.ref = "apache-commons-text" } + +[plugins] +maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" } +gradle-release = { id = "net.researchgate.release", version.ref = "gradle-release" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 zcmb5WV{~QRw(p$^Dz@00IL3?^hro$gg*4VI_WAaTyVM5Foj~O|-84 z$;06hMwt*rV;^8iB z1~&0XWpYJmG?Ts^K9PC62H*`G}xom%S%yq|xvG~FIfP=9*f zZoDRJBm*Y0aId=qJ?7dyb)6)JGWGwe)MHeNSzhi)Ko6J<-m@v=a%NsP537lHe0R* z`If4$aaBA#S=w!2z&m>{lpTy^Lm^mg*3?M&7HFv}7K6x*cukLIGX;bQG|QWdn{%_6 zHnwBKr84#B7Z+AnBXa16a?or^R?+>$4`}{*a_>IhbjvyTtWkHw)|ay)ahWUd-qq$~ zMbh6roVsj;_qnC-R{G+Cy6bApVOinSU-;(DxUEl!i2)1EeQ9`hrfqj(nKI7?Z>Xur zoJz-a`PxkYit1HEbv|jy%~DO^13J-ut986EEG=66S}D3!L}Efp;Bez~7tNq{QsUMm zh9~(HYg1pA*=37C0}n4g&bFbQ+?-h-W}onYeE{q;cIy%eZK9wZjSwGvT+&Cgv z?~{9p(;bY_1+k|wkt_|N!@J~aoY@|U_RGoWX<;p{Nu*D*&_phw`8jYkMNpRTWx1H* z>J-Mi_!`M468#5Aix$$u1M@rJEIOc?k^QBc?T(#=n&*5eS#u*Y)?L8Ha$9wRWdH^3D4|Ps)Y?m0q~SiKiSfEkJ!=^`lJ(%W3o|CZ zSrZL-Xxc{OrmsQD&s~zPfNJOpSZUl%V8tdG%ei}lQkM+z@-4etFPR>GOH9+Y_F<3=~SXln9Kb-o~f>2a6Xz@AS3cn^;c_>lUwlK(n>z?A>NbC z`Ud8^aQy>wy=$)w;JZzA)_*Y$Z5hU=KAG&htLw1Uh00yE!|Nu{EZkch zY9O6x7Y??>!7pUNME*d!=R#s)ghr|R#41l!c?~=3CS8&zr6*aA7n9*)*PWBV2w+&I zpW1-9fr3j{VTcls1>ua}F*bbju_Xq%^v;-W~paSqlf zolj*dt`BBjHI)H9{zrkBo=B%>8}4jeBO~kWqO!~Thi!I1H(in=n^fS%nuL=X2+s!p}HfTU#NBGiwEBF^^tKU zbhhv+0dE-sbK$>J#t-J!B$TMgN@Wh5wTtK2BG}4BGfsZOoRUS#G8Cxv|6EI*n&Xxq zt{&OxCC+BNqz$9b0WM7_PyBJEVObHFh%%`~!@MNZlo*oXDCwDcFwT~Rls!aApL<)^ zbBftGKKBRhB!{?fX@l2_y~%ygNFfF(XJzHh#?`WlSL{1lKT*gJM zs>bd^H9NCxqxn(IOky5k-wALFowQr(gw%|`0991u#9jXQh?4l|l>pd6a&rx|v=fPJ z1mutj{YzpJ_gsClbWFk(G}bSlFi-6@mwoQh-XeD*j@~huW4(8ub%^I|azA)h2t#yG z7e_V_<4jlM3D(I+qX}yEtqj)cpzN*oCdYHa!nm%0t^wHm)EmFP*|FMw!tb@&`G-u~ zK)=Sf6z+BiTAI}}i{*_Ac$ffr*Wrv$F7_0gJkjx;@)XjYSh`RjAgrCck`x!zP>Ifu z&%he4P|S)H*(9oB4uvH67^0}I-_ye_!w)u3v2+EY>eD3#8QR24<;7?*hj8k~rS)~7 zSXs5ww)T(0eHSp$hEIBnW|Iun<_i`}VE0Nc$|-R}wlSIs5pV{g_Dar(Zz<4X3`W?K z6&CAIl4U(Qk-tTcK{|zYF6QG5ArrEB!;5s?tW7 zrE3hcFY&k)+)e{+YOJ0X2uDE_hd2{|m_dC}kgEKqiE9Q^A-+>2UonB+L@v3$9?AYw zVQv?X*pK;X4Ovc6Ev5Gbg{{Eu*7{N3#0@9oMI~}KnObQE#Y{&3mM4`w%wN+xrKYgD zB-ay0Q}m{QI;iY`s1Z^NqIkjrTlf`B)B#MajZ#9u41oRBC1oM1vq0i|F59> z#StM@bHt|#`2)cpl_rWB($DNJ3Lap}QM-+A$3pe}NyP(@+i1>o^fe-oxX#Bt`mcQc zb?pD4W%#ep|3%CHAYnr*^M6Czg>~L4?l16H1OozM{P*en298b+`i4$|w$|4AHbzqB zHpYUsHZET$Z0ztC;U+0*+amF!@PI%^oUIZy{`L{%O^i{Xk}X0&nl)n~tVEpcAJSJ} zverw15zP1P-O8h9nd!&hj$zuwjg?DoxYIw{jWM zW5_pj+wFy8Tsa9g<7Qa21WaV&;ejoYflRKcz?#fSH_)@*QVlN2l4(QNk| z4aPnv&mrS&0|6NHq05XQw$J^RR9T{3SOcMKCXIR1iSf+xJ0E_Wv?jEc*I#ZPzyJN2 zUG0UOXHl+PikM*&g$U@g+KbG-RY>uaIl&DEtw_Q=FYq?etc!;hEC_}UX{eyh%dw2V zTTSlap&5>PY{6I#(6`j-9`D&I#|YPP8a;(sOzgeKDWsLa!i-$frD>zr-oid!Hf&yS z!i^cr&7tN}OOGmX2)`8k?Tn!!4=tz~3hCTq_9CdiV!NIblUDxHh(FJ$zs)B2(t5@u z-`^RA1ShrLCkg0)OhfoM;4Z{&oZmAec$qV@ zGQ(7(!CBk<5;Ar%DLJ0p0!ResC#U<+3i<|vib1?{5gCebG7$F7URKZXuX-2WgF>YJ^i zMhHDBsh9PDU8dlZ$yJKtc6JA#y!y$57%sE>4Nt+wF1lfNIWyA`=hF=9Gj%sRwi@vd z%2eVV3y&dvAgyuJ=eNJR+*080dbO_t@BFJO<@&#yqTK&+xc|FRR;p;KVk@J3$S{p` zGaMj6isho#%m)?pOG^G0mzOAw0z?!AEMsv=0T>WWcE>??WS=fII$t$(^PDPMU(P>o z_*0s^W#|x)%tx8jIgZY~A2yG;US0m2ZOQt6yJqW@XNY_>_R7(Nxb8Ged6BdYW6{prd!|zuX$@Q2o6Ona8zzYC1u!+2!Y$Jc9a;wy+pXt}o6~Bu1oF1c zp7Y|SBTNi@=I(K%A60PMjM#sfH$y*c{xUgeSpi#HB`?|`!Tb&-qJ3;vxS!TIzuTZs-&%#bAkAyw9m4PJgvey zM5?up*b}eDEY+#@tKec)-c(#QF0P?MRlD1+7%Yk*jW;)`f;0a-ZJ6CQA?E%>i2Dt7T9?s|9ZF|KP4;CNWvaVKZ+Qeut;Jith_y{v*Ny6Co6!8MZx;Wgo z=qAi%&S;8J{iyD&>3CLCQdTX*$+Rx1AwA*D_J^0>suTgBMBb=*hefV+Ars#mmr+YsI3#!F@Xc1t4F-gB@6aoyT+5O(qMz*zG<9Qq*f0w^V!03rpr*-WLH}; zfM{xSPJeu6D(%8HU%0GEa%waFHE$G?FH^kMS-&I3)ycx|iv{T6Wx}9$$D&6{%1N_8 z_CLw)_9+O4&u94##vI9b-HHm_95m)fa??q07`DniVjAy`t7;)4NpeyAY(aAk(+T_O z1om+b5K2g_B&b2DCTK<>SE$Ode1DopAi)xaJjU>**AJK3hZrnhEQ9E`2=|HHe<^tv z63e(bn#fMWuz>4erc47}!J>U58%<&N<6AOAewyzNTqi7hJc|X{782&cM zHZYclNbBwU6673=!ClmxMfkC$(CykGR@10F!zN1Se83LR&a~$Ht&>~43OX22mt7tcZUpa;9@q}KDX3O&Ugp6< zLZLfIMO5;pTee1vNyVC$FGxzK2f>0Z-6hM82zKg44nWo|n}$Zk6&;5ry3`(JFEX$q zK&KivAe${e^5ZGc3a9hOt|!UOE&OocpVryE$Y4sPcs4rJ>>Kbi2_subQ9($2VN(3o zb~tEzMsHaBmBtaHAyES+d3A(qURgiskSSwUc9CfJ@99&MKp2sooSYZu+-0t0+L*!I zYagjOlPgx|lep9tiU%ts&McF6b0VE57%E0Ho%2oi?=Ks+5%aj#au^OBwNwhec zta6QAeQI^V!dF1C)>RHAmB`HnxyqWx?td@4sd15zPd*Fc9hpDXP23kbBenBxGeD$k z;%0VBQEJ-C)&dTAw_yW@k0u?IUk*NrkJ)(XEeI z9Y>6Vel>#s_v@=@0<{4A{pl=9cQ&Iah0iD0H`q)7NeCIRz8zx;! z^OO;1+IqoQNak&pV`qKW+K0^Hqp!~gSohcyS)?^P`JNZXw@gc6{A3OLZ?@1Uc^I2v z+X!^R*HCm3{7JPq{8*Tn>5;B|X7n4QQ0Bs79uTU%nbqOJh`nX(BVj!#f;#J+WZxx4 z_yM&1Y`2XzhfqkIMO7tB3raJKQS+H5F%o83bM+hxbQ zeeJm=Dvix$2j|b4?mDacb67v-1^lTp${z=jc1=j~QD>7c*@+1?py>%Kj%Ejp7Y-!? z8iYRUlGVrQPandAaxFfks53@2EC#0)%mrnmGRn&>=$H$S8q|kE_iWko4`^vCS2aWg z#!`RHUGyOt*k?bBYu3*j3u0gB#v(3tsije zgIuNNWNtrOkx@Pzs;A9un+2LX!zw+p3_NX^Sh09HZAf>m8l@O*rXy_82aWT$Q>iyy zqO7Of)D=wcSn!0+467&!Hl))eff=$aneB?R!YykdKW@k^_uR!+Q1tR)+IJb`-6=jj zymzA>Sv4>Z&g&WWu#|~GcP7qP&m*w-S$)7Xr;(duqCTe7p8H3k5>Y-n8438+%^9~K z3r^LIT_K{i7DgEJjIocw_6d0!<;wKT`X;&vv+&msmhAAnIe!OTdybPctzcEzBy88_ zWO{6i4YT%e4^WQZB)KHCvA(0tS zHu_Bg+6Ko%a9~$EjRB90`P(2~6uI@SFibxct{H#o&y40MdiXblu@VFXbhz>Nko;7R z70Ntmm-FePqhb%9gL+7U8@(ch|JfH5Fm)5${8|`Lef>LttM_iww6LW2X61ldBmG0z zax3y)njFe>j*T{i0s8D4=L>X^j0)({R5lMGVS#7(2C9@AxL&C-lZQx~czI7Iv+{%1 z2hEG>RzX4S8x3v#9sgGAnPzptM)g&LB}@%E>fy0vGSa(&q0ch|=ncKjNrK z`jA~jObJhrJ^ri|-)J^HUyeZXz~XkBp$VhcTEcTdc#a2EUOGVX?@mYx#Vy*!qO$Jv zQ4rgOJ~M*o-_Wptam=~krnmG*p^j!JAqoQ%+YsDFW7Cc9M%YPiBOrVcD^RY>m9Pd< zu}#9M?K{+;UIO!D9qOpq9yxUquQRmQNMo0pT`@$pVt=rMvyX)ph(-CCJLvUJy71DI zBk7oc7)-%ngdj~s@76Yse3L^gV0 z2==qfp&Q~L(+%RHP0n}+xH#k(hPRx(!AdBM$JCfJ5*C=K3ts>P?@@SZ_+{U2qFZb>4kZ{Go37{# zSQc+-dq*a-Vy4?taS&{Ht|MLRiS)Sn14JOONyXqPNnpq&2y~)6wEG0oNy>qvod$FF z`9o&?&6uZjhZ4_*5qWVrEfu(>_n2Xi2{@Gz9MZ8!YmjYvIMasE9yVQL10NBrTCczq zcTY1q^PF2l!Eraguf{+PtHV3=2A?Cu&NN&a8V(y;q(^_mFc6)%Yfn&X&~Pq zU1?qCj^LF(EQB1F`8NxNjyV%fde}dEa(Hx=r7$~ts2dzDwyi6ByBAIx$NllB4%K=O z$AHz1<2bTUb>(MCVPpK(E9wlLElo(aSd(Os)^Raum`d(g9Vd_+Bf&V;l=@mM=cC>) z)9b0enb)u_7V!!E_bl>u5nf&Rl|2r=2F3rHMdb7y9E}}F82^$Rf+P8%dKnOeKh1vs zhH^P*4Ydr^$)$h@4KVzxrHyy#cKmWEa9P5DJ|- zG;!Qi35Tp7XNj60=$!S6U#!(${6hyh7d4q=pF{`0t|N^|L^d8pD{O9@tF~W;#Je*P z&ah%W!KOIN;SyAEhAeTafJ4uEL`(RtnovM+cb(O#>xQnk?dzAjG^~4$dFn^<@-Na3 z395;wBnS{t*H;Jef2eE!2}u5Ns{AHj>WYZDgQJt8v%x?9{MXqJsGP|l%OiZqQ1aB! z%E=*Ig`(!tHh>}4_z5IMpg{49UvD*Pp9!pxt_gdAW%sIf3k6CTycOT1McPl=_#0?8 zVjz8Hj*Vy9c5-krd-{BQ{6Xy|P$6LJvMuX$* zA+@I_66_ET5l2&gk9n4$1M3LN8(yEViRx&mtd#LD}AqEs?RW=xKC(OCWH;~>(X6h!uDxXIPH06xh z*`F4cVlbDP`A)-fzf>MuScYsmq&1LUMGaQ3bRm6i7OsJ|%uhTDT zlvZA1M}nz*SalJWNT|`dBm1$xlaA>CCiQ zK`xD-RuEn>-`Z?M{1%@wewf#8?F|(@1e0+T4>nmlSRrNK5f)BJ2H*$q(H>zGD0>eL zQ!tl_Wk)k*e6v^m*{~A;@6+JGeWU-q9>?+L_#UNT%G?4&BnOgvm9@o7l?ov~XL+et zbGT)|G7)KAeqb=wHSPk+J1bdg7N3$vp(ekjI1D9V$G5Cj!=R2w=3*4!z*J-r-cyeb zd(i2KmX!|Lhey!snRw z?#$Gu%S^SQEKt&kep)up#j&9}e+3=JJBS(s>MH+|=R(`8xK{mmndWo_r`-w1#SeRD&YtAJ#GiVI*TkQZ}&aq<+bU2+coU3!jCI6E+Ad_xFW*ghnZ$q zAoF*i&3n1j#?B8x;kjSJD${1jdRB;)R*)Ao!9bd|C7{;iqDo|T&>KSh6*hCD!rwv= zyK#F@2+cv3=|S1Kef(E6Niv8kyLVLX&e=U;{0x{$tDfShqkjUME>f8d(5nzSkY6@! z^-0>DM)wa&%m#UF1F?zR`8Y3X#tA!*7Q$P3lZJ%*KNlrk_uaPkxw~ zxZ1qlE;Zo;nb@!SMazSjM>;34ROOoygo%SF);LL>rRonWwR>bmSd1XD^~sGSu$Gg# zFZ`|yKU0%!v07dz^v(tY%;So(e`o{ZYTX`hm;@b0%8|H>VW`*cr8R%3n|ehw2`(9B+V72`>SY}9^8oh$En80mZK9T4abVG*to;E z1_S6bgDOW?!Oy1LwYy=w3q~KKdbNtyH#d24PFjX)KYMY93{3-mPP-H>@M-_>N~DDu zENh~reh?JBAK=TFN-SfDfT^=+{w4ea2KNWXq2Y<;?(gf(FgVp8Zp-oEjKzB%2Iqj;48GmY3h=bcdYJ}~&4tS`Q1sb=^emaW$IC$|R+r-8V- zf0$gGE(CS_n4s>oicVk)MfvVg#I>iDvf~Ov8bk}sSxluG!6#^Z_zhB&U^`eIi1@j( z^CK$z^stBHtaDDHxn+R;3u+>Lil^}fj?7eaGB z&5nl^STqcaBxI@v>%zG|j))G(rVa4aY=B@^2{TFkW~YP!8!9TG#(-nOf^^X-%m9{Z zCC?iC`G-^RcBSCuk=Z`(FaUUe?hf3{0C>>$?Vs z`2Uud9M+T&KB6o4o9kvdi^Q=Bw!asPdxbe#W-Oaa#_NP(qpyF@bVxv5D5))srkU#m zj_KA+#7sqDn*Ipf!F5Byco4HOSd!Ui$l94|IbW%Ny(s1>f4|Mv^#NfB31N~kya9!k zWCGL-$0ZQztBate^fd>R!hXY_N9ZjYp3V~4_V z#eB)Kjr8yW=+oG)BuNdZG?jaZlw+l_ma8aET(s+-x+=F-t#Qoiuu1i`^x8Sj>b^U} zs^z<()YMFP7CmjUC@M=&lA5W7t&cxTlzJAts*%PBDAPuqcV5o7HEnqjif_7xGt)F% zGx2b4w{@!tE)$p=l3&?Bf#`+!-RLOleeRk3 z7#pF|w@6_sBmn1nECqdunmG^}pr5(ZJQVvAt$6p3H(16~;vO>?sTE`Y+mq5YP&PBo zvq!7#W$Gewy`;%6o^!Dtjz~x)T}Bdk*BS#=EY=ODD&B=V6TD2z^hj1m5^d6s)D*wk zu$z~D7QuZ2b?5`p)E8e2_L38v3WE{V`bVk;6fl#o2`) z99JsWhh?$oVRn@$S#)uK&8DL8>An0&S<%V8hnGD7Z^;Y(%6;^9!7kDQ5bjR_V+~wp zfx4m3z6CWmmZ<8gDGUyg3>t8wgJ5NkkiEm^(sedCicP^&3D%}6LtIUq>mXCAt{9eF zNXL$kGcoUTf_Lhm`t;hD-SE)m=iBnxRU(NyL}f6~1uH)`K!hmYZjLI%H}AmEF5RZt z06$wn63GHnApHXZZJ}s^s)j9(BM6e*7IBK6Bq(!)d~zR#rbxK9NVIlgquoMq z=eGZ9NR!SEqP6=9UQg#@!rtbbSBUM#ynF);zKX+|!Zm}*{H z+j=d?aZ2!?@EL7C~%B?6ouCKLnO$uWn;Y6Xz zX8dSwj732u(o*U3F$F=7xwxm>E-B+SVZH;O-4XPuPkLSt_?S0)lb7EEg)Mglk0#eS z9@jl(OnH4juMxY+*r03VDfPx_IM!Lmc(5hOI;`?d37f>jPP$?9jQQIQU@i4vuG6MagEoJrQ=RD7xt@8E;c zeGV*+Pt+t$@pt!|McETOE$9k=_C!70uhwRS9X#b%ZK z%q(TIUXSS^F0`4Cx?Rk07C6wI4!UVPeI~-fxY6`YH$kABdOuiRtl73MqG|~AzZ@iL&^s?24iS;RK_pdlWkhcF z@Wv-Om(Aealfg)D^adlXh9Nvf~Uf@y;g3Y)i(YP zEXDnb1V}1pJT5ZWyw=1i+0fni9yINurD=EqH^ciOwLUGi)C%Da)tyt=zq2P7pV5-G zR7!oq28-Fgn5pW|nlu^b!S1Z#r7!Wtr{5J5PQ>pd+2P7RSD?>(U7-|Y z7ZQ5lhYIl_IF<9?T9^IPK<(Hp;l5bl5tF9>X-zG14_7PfsA>6<$~A338iYRT{a@r_ zuXBaT=`T5x3=s&3=RYx6NgG>No4?5KFBVjE(swfcivcIpPQFx5l+O;fiGsOrl5teR z_Cm+;PW}O0Dwe_(4Z@XZ)O0W-v2X><&L*<~*q3dg;bQW3g7)a#3KiQP>+qj|qo*Hk z?57>f2?f@`=Fj^nkDKeRkN2d$Z@2eNKpHo}ksj-$`QKb6n?*$^*%Fb3_Kbf1(*W9K>{L$mud2WHJ=j0^=g30Xhg8$#g^?36`p1fm;;1@0Lrx+8t`?vN0ZorM zSW?rhjCE8$C|@p^sXdx z|NOHHg+fL;HIlqyLp~SSdIF`TnSHehNCU9t89yr@)FY<~hu+X`tjg(aSVae$wDG*C zq$nY(Y494R)hD!i1|IIyP*&PD_c2FPgeY)&mX1qujB1VHPG9`yFQpLFVQ0>EKS@Bp zAfP5`C(sWGLI?AC{XEjLKR4FVNw(4+9b?kba95ukgR1H?w<8F7)G+6&(zUhIE5Ef% z=fFkL3QKA~M@h{nzjRq!Y_t!%U66#L8!(2-GgFxkD1=JRRqk=n%G(yHKn%^&$dW>; zSjAcjETMz1%205se$iH_)ZCpfg_LwvnsZQAUCS#^FExp8O4CrJb6>JquNV@qPq~3A zZ<6dOU#6|8+fcgiA#~MDmcpIEaUO02L5#T$HV0$EMD94HT_eXLZ2Zi&(! z&5E>%&|FZ`)CN10tM%tLSPD*~r#--K(H-CZqIOb99_;m|D5wdgJ<1iOJz@h2Zkq?} z%8_KXb&hf=2Wza(Wgc;3v3TN*;HTU*q2?#z&tLn_U0Nt!y>Oo>+2T)He6%XuP;fgn z-G!#h$Y2`9>Jtf}hbVrm6D70|ERzLAU>3zoWhJmjWfgM^))T+2u$~5>HF9jQDkrXR z=IzX36)V75PrFjkQ%TO+iqKGCQ-DDXbaE;C#}!-CoWQx&v*vHfyI>$HNRbpvm<`O( zlx9NBWD6_e&J%Ous4yp~s6)Ghni!I6)0W;9(9$y1wWu`$gs<$9Mcf$L*piP zPR0Av*2%ul`W;?-1_-5Zy0~}?`e@Y5A&0H!^ApyVTT}BiOm4GeFo$_oPlDEyeGBbh z1h3q&Dx~GmUS|3@4V36&$2uO8!Yp&^pD7J5&TN{?xphf*-js1fP?B|`>p_K>lh{ij zP(?H%e}AIP?_i^f&Li=FDSQ`2_NWxL+BB=nQr=$ zHojMlXNGauvvwPU>ZLq!`bX-5F4jBJ&So{kE5+ms9UEYD{66!|k~3vsP+mE}x!>%P za98bAU0!h0&ka4EoiDvBM#CP#dRNdXJcb*(%=<(g+M@<)DZ!@v1V>;54En?igcHR2 zhubQMq}VSOK)onqHfczM7YA@s=9*ow;k;8)&?J3@0JiGcP! zP#00KZ1t)GyZeRJ=f0^gc+58lc4Qh*S7RqPIC6GugG1gXe$LIQMRCo8cHf^qXgAa2 z`}t>u2Cq1CbSEpLr~E=c7~=Qkc9-vLE%(v9N*&HF`(d~(0`iukl5aQ9u4rUvc8%m) zr2GwZN4!s;{SB87lJB;veebPmqE}tSpT>+`t?<457Q9iV$th%i__Z1kOMAswFldD6 ztbOvO337S5o#ZZgN2G99_AVqPv!?Gmt3pzgD+Hp3QPQ`9qJ(g=kjvD+fUSS3upJn! zqoG7acIKEFRX~S}3|{EWT$kdz#zrDlJU(rPkxjws_iyLKU8+v|*oS_W*-guAb&Pj1 z35Z`3z<&Jb@2Mwz=KXucNYdY#SNO$tcVFr9KdKm|%^e-TXzs6M`PBper%ajkrIyUe zp$vVxVs9*>Vp4_1NC~Zg)WOCPmOxI1V34QlG4!aSFOH{QqSVq1^1)- z0P!Z?tT&E-ll(pwf0?=F=yOzik=@nh1Clxr9}Vij89z)ePDSCYAqw?lVI?v?+&*zH z)p$CScFI8rrwId~`}9YWPFu0cW1Sf@vRELs&cbntRU6QfPK-SO*mqu|u~}8AJ!Q$z znzu}50O=YbjwKCuSVBs6&CZR#0FTu)3{}qJJYX(>QPr4$RqWiwX3NT~;>cLn*_&1H zaKpIW)JVJ>b{uo2oq>oQt3y=zJjb%fU@wLqM{SyaC6x2snMx-}ivfU<1- znu1Lh;i$3Tf$Kh5Uk))G!D1UhE8pvx&nO~w^fG)BC&L!_hQk%^p`Kp@F{cz>80W&T ziOK=Sq3fdRu*V0=S53rcIfWFazI}Twj63CG(jOB;$*b`*#B9uEnBM`hDk*EwSRdwP8?5T?xGUKs=5N83XsR*)a4|ijz|c{4tIU+4j^A5C<#5 z*$c_d=5ml~%pGxw#?*q9N7aRwPux5EyqHVkdJO=5J>84!X6P>DS8PTTz>7C#FO?k#edkntG+fJk8ZMn?pmJSO@`x-QHq;7^h6GEXLXo1TCNhH z8ZDH{*NLAjo3WM`xeb=X{((uv3H(8&r8fJJg_uSs_%hOH%JDD?hu*2NvWGYD+j)&` zz#_1%O1wF^o5ryt?O0n;`lHbzp0wQ?rcbW(F1+h7_EZZ9{>rePvLAPVZ_R|n@;b$;UchU=0j<6k8G9QuQf@76oiE*4 zXOLQ&n3$NR#p4<5NJMVC*S);5x2)eRbaAM%VxWu9ohlT;pGEk7;002enCbQ>2r-us z3#bpXP9g|mE`65VrN`+3mC)M(eMj~~eOf)do<@l+fMiTR)XO}422*1SL{wyY(%oMpBgJagtiDf zz>O6(m;};>Hi=t8o{DVC@YigqS(Qh+ix3Rwa9aliH}a}IlOCW1@?%h_bRbq-W{KHF z%Vo?-j@{Xi@=~Lz5uZP27==UGE15|g^0gzD|3x)SCEXrx`*MP^FDLl%pOi~~Il;dc z^hrwp9sYeT7iZ)-ajKy@{a`kr0-5*_!XfBpXwEcFGJ;%kV$0Nx;apKrur zJN2J~CAv{Zjj%FolyurtW8RaFmpn&zKJWL>(0;;+q(%(Hx!GMW4AcfP0YJ*Vz!F4g z!ZhMyj$BdXL@MlF%KeInmPCt~9&A!;cRw)W!Hi@0DY(GD_f?jeV{=s=cJ6e}JktJw zQORnxxj3mBxfrH=x{`_^Z1ddDh}L#V7i}$njUFRVwOX?qOTKjfPMBO4y(WiU<)epb zvB9L=%jW#*SL|Nd_G?E*_h1^M-$PG6Pc_&QqF0O-FIOpa4)PAEPsyvB)GKasmBoEt z?_Q2~QCYGH+hW31x-B=@5_AN870vY#KB~3a*&{I=f);3Kv7q4Q7s)0)gVYx2#Iz9g(F2;=+Iy4 z6KI^8GJ6D@%tpS^8boU}zpi=+(5GfIR)35PzrbuXeL1Y1N%JK7PG|^2k3qIqHfX;G zQ}~JZ-UWx|60P5?d1e;AHx!_;#PG%d=^X(AR%i`l0jSpYOpXoKFW~7ip7|xvN;2^? zsYC9fanpO7rO=V7+KXqVc;Q5z%Bj})xHVrgoR04sA2 zl~DAwv=!(()DvH*=lyhIlU^hBkA0$e*7&fJpB0|oB7)rqGK#5##2T`@_I^|O2x4GO z;xh6ROcV<9>?e0)MI(y++$-ksV;G;Xe`lh76T#Htuia+(UrIXrf9?

L(tZ$0BqX1>24?V$S+&kLZ`AodQ4_)P#Q3*4xg8}lMV-FLwC*cN$< zt65Rf%7z41u^i=P*qO8>JqXPrinQFapR7qHAtp~&RZ85$>ob|Js;GS^y;S{XnGiBc zGa4IGvDl?x%gY`vNhv8wgZnP#UYI-w*^4YCZnxkF85@ldepk$&$#3EAhrJY0U)lR{F6sM3SONV^+$;Zx8BD&Eku3K zKNLZyBni3)pGzU0;n(X@1fX8wYGKYMpLmCu{N5-}epPDxClPFK#A@02WM3!myN%bkF z|GJ4GZ}3sL{3{qXemy+#Uk{4>Kf8v11;f8I&c76+B&AQ8udd<8gU7+BeWC`akUU~U zgXoxie>MS@rBoyY8O8Tc&8id!w+_ooxcr!1?#rc$-|SBBtH6S?)1e#P#S?jFZ8u-Bs&k`yLqW|{j+%c#A4AQ>+tj$Y z^CZajspu$F%73E68Lw5q7IVREED9r1Ijsg#@DzH>wKseye>hjsk^{n0g?3+gs@7`i zHx+-!sjLx^fS;fY!ERBU+Q zVJ!e0hJH%P)z!y%1^ZyG0>PN@5W~SV%f>}c?$H8r;Sy-ui>aruVTY=bHe}$e zi&Q4&XK!qT7-XjCrDaufT@>ieQ&4G(SShUob0Q>Gznep9fR783jGuUynAqc6$pYX; z7*O@@JW>O6lKIk0G00xsm|=*UVTQBB`u1f=6wGAj%nHK_;Aqmfa!eAykDmi-@u%6~ z;*c!pS1@V8r@IX9j&rW&d*}wpNs96O2Ute>%yt{yv>k!6zfT6pru{F1M3P z2WN1JDYqoTB#(`kE{H676QOoX`cnqHl1Yaru)>8Ky~VU{)r#{&s86Vz5X)v15ULHA zAZDb{99+s~qI6;-dQ5DBjHJP@GYTwn;Dv&9kE<0R!d z8tf1oq$kO`_sV(NHOSbMwr=To4r^X$`sBW4$gWUov|WY?xccQJN}1DOL|GEaD_!@& z15p?Pj+>7d`@LvNIu9*^hPN)pwcv|akvYYq)ks%`G>!+!pW{-iXPZsRp8 z35LR;DhseQKWYSD`%gO&k$Dj6_6q#vjWA}rZcWtQr=Xn*)kJ9kacA=esi*I<)1>w^ zO_+E>QvjP)qiSZg9M|GNeLtO2D7xT6vsj`88sd!94j^AqxFLi}@w9!Y*?nwWARE0P znuI_7A-saQ+%?MFA$gttMV-NAR^#tjl_e{R$N8t2NbOlX373>e7Ox=l=;y#;M7asp zRCz*CLnrm$esvSb5{T<$6CjY zmZ(i{Rs_<#pWW>(HPaaYj`%YqBra=Ey3R21O7vUbzOkJJO?V`4-D*u4$Me0Bx$K(lYo`JO}gnC zx`V}a7m-hLU9Xvb@K2ymioF)vj12<*^oAqRuG_4u%(ah?+go%$kOpfb`T96P+L$4> zQ#S+sA%VbH&mD1k5Ak7^^dZoC>`1L%i>ZXmooA!%GI)b+$D&ziKrb)a=-ds9xk#~& z7)3iem6I|r5+ZrTRe_W861x8JpD`DDIYZNm{$baw+$)X^Jtjnl0xlBgdnNY}x%5za zkQ8E6T<^$sKBPtL4(1zi_Rd(tVth*3Xs!ulflX+70?gb&jRTnI8l+*Aj9{|d%qLZ+ z>~V9Z;)`8-lds*Zgs~z1?Fg?Po7|FDl(Ce<*c^2=lFQ~ahwh6rqSjtM5+$GT>3WZW zj;u~w9xwAhOc<kF}~`CJ68 z?(S5vNJa;kriPlim33{N5`C{9?NWhzsna_~^|K2k4xz1`xcui*LXL-1#Y}Hi9`Oo!zQ>x-kgAX4LrPz63uZ+?uG*84@PKq-KgQlMNRwz=6Yes) zY}>YN+qP}nwr$(CZQFjUOI=-6J$2^XGvC~EZ+vrqWaOXB$k?%Suf5k=4>AveC1aJ! ziaW4IS%F$_Babi)kA8Y&u4F7E%99OPtm=vzw$$ zEz#9rvn`Iot_z-r3MtV>k)YvErZ<^Oa${`2>MYYODSr6?QZu+be-~MBjwPGdMvGd!b!elsdi4% z`37W*8+OGulab8YM?`KjJ8e+jM(tqLKSS@=jimq3)Ea2EB%88L8CaM+aG7;27b?5` z4zuUWBr)f)k2o&xg{iZ$IQkJ+SK>lpq4GEacu~eOW4yNFLU!Kgc{w4&D$4ecm0f}~ zTTzquRW@`f0}|IILl`!1P+;69g^upiPA6F{)U8)muWHzexRenBU$E^9X-uIY2%&1w z_=#5*(nmxJ9zF%styBwivi)?#KMG96-H@hD-H_&EZiRNsfk7mjBq{L%!E;Sqn!mVX*}kXhwH6eh;b42eD!*~upVG@ z#smUqz$ICm!Y8wY53gJeS|Iuard0=;k5i5Z_hSIs6tr)R4n*r*rE`>38Pw&lkv{_r!jNN=;#?WbMj|l>cU(9trCq; z%nN~r^y7!kH^GPOf3R}?dDhO=v^3BeP5hF|%4GNQYBSwz;x({21i4OQY->1G=KFyu z&6d`f2tT9Yl_Z8YACZaJ#v#-(gcyeqXMhYGXb=t>)M@fFa8tHp2x;ODX=Ap@a5I=U z0G80^$N0G4=U(>W%mrrThl0DjyQ-_I>+1Tdd_AuB3qpYAqY54upwa3}owa|x5iQ^1 zEf|iTZxKNGRpI>34EwkIQ2zHDEZ=(J@lRaOH>F|2Z%V_t56Km$PUYu^xA5#5Uj4I4RGqHD56xT%H{+P8Ag>e_3pN$4m8n>i%OyJFPNWaEnJ4McUZPa1QmOh?t8~n& z&RulPCors8wUaqMHECG=IhB(-tU2XvHP6#NrLVyKG%Ee*mQ5Ps%wW?mcnriTVRc4J`2YVM>$ixSF2Xi+Wn(RUZnV?mJ?GRdw%lhZ+t&3s7g!~g{%m&i<6 z5{ib-<==DYG93I(yhyv4jp*y3#*WNuDUf6`vTM%c&hiayf(%=x@4$kJ!W4MtYcE#1 zHM?3xw63;L%x3drtd?jot!8u3qeqctceX3m;tWetK+>~q7Be$h>n6riK(5@ujLgRS zvOym)k+VAtyV^mF)$29Y`nw&ijdg~jYpkx%*^ z8dz`C*g=I?;clyi5|!27e2AuSa$&%UyR(J3W!A=ZgHF9OuKA34I-1U~pyD!KuRkjA zbkN!?MfQOeN>DUPBxoy5IX}@vw`EEB->q!)8fRl_mqUVuRu|C@KD-;yl=yKc=ZT0% zB$fMwcC|HE*0f8+PVlWHi>M`zfsA(NQFET?LrM^pPcw`cK+Mo0%8*x8@65=CS_^$cG{GZQ#xv($7J z??R$P)nPLodI;P!IC3eEYEHh7TV@opr#*)6A-;EU2XuogHvC;;k1aI8asq7ovoP!* z?x%UoPrZjj<&&aWpsbr>J$Er-7!E(BmOyEv!-mbGQGeJm-U2J>74>o5x`1l;)+P&~ z>}f^=Rx(ZQ2bm+YE0u=ZYrAV@apyt=v1wb?R@`i_g64YyAwcOUl=C!i>=Lzb$`tjv zOO-P#A+)t-JbbotGMT}arNhJmmGl-lyUpMn=2UacVZxmiG!s!6H39@~&uVokS zG=5qWhfW-WOI9g4!R$n7!|ViL!|v3G?GN6HR0Pt_L5*>D#FEj5wM1DScz4Jv@Sxnl zB@MPPmdI{(2D?;*wd>3#tjAirmUnQoZrVv`xM3hARuJksF(Q)wd4P$88fGYOT1p6U z`AHSN!`St}}UMBT9o7i|G`r$ zrB=s$qV3d6$W9@?L!pl0lf%)xs%1ko^=QY$ty-57=55PvP(^6E7cc zGJ*>m2=;fOj?F~yBf@K@9qwX0hA803Xw+b0m}+#a(>RyR8}*Y<4b+kpp|OS+!whP( zH`v{%s>jsQI9rd$*vm)EkwOm#W_-rLTHcZRek)>AtF+~<(did)*oR1|&~1|e36d-d zgtm5cv1O0oqgWC%Et@P4Vhm}Ndl(Y#C^MD03g#PH-TFy+7!Osv1z^UWS9@%JhswEq~6kSr2DITo59+; ze=ZC}i2Q?CJ~Iyu?vn|=9iKV>4j8KbxhE4&!@SQ^dVa-gK@YfS9xT(0kpW*EDjYUkoj! zE49{7H&E}k%5(>sM4uGY)Q*&3>{aitqdNnRJkbOmD5Mp5rv-hxzOn80QsG=HJ_atI-EaP69cacR)Uvh{G5dTpYG7d zbtmRMq@Sexey)||UpnZ?;g_KMZq4IDCy5}@u!5&B^-=6yyY{}e4Hh3ee!ZWtL*s?G zxG(A!<9o!CL+q?u_utltPMk+hn?N2@?}xU0KlYg?Jco{Yf@|mSGC<(Zj^yHCvhmyx z?OxOYoxbptDK()tsJ42VzXdINAMWL$0Gcw?G(g8TMB)Khw_|v9`_ql#pRd2i*?CZl z7k1b!jQB=9-V@h%;Cnl7EKi;Y^&NhU0mWEcj8B|3L30Ku#-9389Q+(Yet0r$F=+3p z6AKOMAIi|OHyzlHZtOm73}|ntKtFaXF2Fy|M!gOh^L4^62kGUoWS1i{9gsds_GWBc zLw|TaLP64z3z9?=R2|T6Xh2W4_F*$cq>MtXMOy&=IPIJ`;!Tw?PqvI2b*U1)25^<2 zU_ZPoxg_V0tngA0J+mm?3;OYw{i2Zb4x}NedZug!>EoN3DC{1i)Z{Z4m*(y{ov2%- zk(w>+scOO}MN!exSc`TN)!B=NUX`zThWO~M*ohqq;J2hx9h9}|s#?@eR!=F{QTrq~ zTcY|>azkCe$|Q0XFUdpFT=lTcyW##i;-e{}ORB4D?t@SfqGo_cS z->?^rh$<&n9DL!CF+h?LMZRi)qju!meugvxX*&jfD!^1XB3?E?HnwHP8$;uX{Rvp# zh|)hM>XDv$ZGg=$1{+_bA~u-vXqlw6NH=nkpyWE0u}LQjF-3NhATL@9rRxMnpO%f7 z)EhZf{PF|mKIMFxnC?*78(}{Y)}iztV12}_OXffJ;ta!fcFIVjdchyHxH=t%ci`Xd zX2AUB?%?poD6Zv*&BA!6c5S#|xn~DK01#XvjT!w!;&`lDXSJT4_j$}!qSPrb37vc{ z9^NfC%QvPu@vlxaZ;mIbn-VHA6miwi8qJ~V;pTZkKqqOii<1Cs}0i?uUIss;hM4dKq^1O35y?Yp=l4i zf{M!@QHH~rJ&X~8uATV><23zZUbs-J^3}$IvV_ANLS08>k`Td7aU_S1sLsfi*C-m1 z-e#S%UGs4E!;CeBT@9}aaI)qR-6NU@kvS#0r`g&UWg?fC7|b^_HyCE!8}nyh^~o@< zpm7PDFs9yxp+byMS(JWm$NeL?DNrMCNE!I^ko-*csB+dsf4GAq{=6sfyf4wb>?v1v zmb`F*bN1KUx-`ra1+TJ37bXNP%`-Fd`vVQFTwWpX@;s(%nDQa#oWhgk#mYlY*!d>( zE&!|ySF!mIyfING+#%RDY3IBH_fW$}6~1%!G`suHub1kP@&DoAd5~7J55;5_noPI6eLf{t;@9Kf<{aO0`1WNKd?<)C-|?C?)3s z>wEq@8=I$Wc~Mt$o;g++5qR+(6wt9GI~pyrDJ%c?gPZe)owvy^J2S=+M^ z&WhIE`g;;J^xQLVeCtf7b%Dg#Z2gq9hp_%g)-%_`y*zb; zn9`f`mUPN-Ts&fFo(aNTsXPA|J!TJ{0hZp0^;MYHLOcD=r_~~^ymS8KLCSeU3;^QzJNqS z5{5rEAv#l(X?bvwxpU;2%pQftF`YFgrD1jt2^~Mt^~G>T*}A$yZc@(k9orlCGv&|1 zWWvVgiJsCAtamuAYT~nzs?TQFt<1LSEx!@e0~@yd6$b5!Zm(FpBl;(Cn>2vF?k zOm#TTjFwd2D-CyA!mqR^?#Uwm{NBemP>(pHmM}9;;8`c&+_o3#E5m)JzfwN?(f-a4 zyd%xZc^oQx3XT?vcCqCX&Qrk~nu;fxs@JUoyVoi5fqpi&bUhQ2y!Ok2pzsFR(M(|U zw3E+kH_zmTRQ9dUMZWRE%Zakiwc+lgv7Z%|YO9YxAy`y28`Aw;WU6HXBgU7fl@dnt z-fFBV)}H-gqP!1;V@Je$WcbYre|dRdp{xt!7sL3Eoa%IA`5CAA%;Wq8PktwPdULo! z8!sB}Qt8#jH9Sh}QiUtEPZ6H0b*7qEKGJ%ITZ|vH)5Q^2m<7o3#Z>AKc%z7_u`rXA zqrCy{-{8;9>dfllLu$^M5L z-hXs))h*qz%~ActwkIA(qOVBZl2v4lwbM>9l70Y`+T*elINFqt#>OaVWoja8RMsep z6Or3f=oBnA3vDbn*+HNZP?8LsH2MY)x%c13@(XfuGR}R?Nu<|07{$+Lc3$Uv^I!MQ z>6qWgd-=aG2Y^24g4{Bw9ueOR)(9h`scImD=86dD+MnSN4$6 z^U*o_mE-6Rk~Dp!ANp#5RE9n*LG(Vg`1)g6!(XtDzsov$Dvz|Gv1WU68J$CkshQhS zCrc|cdkW~UK}5NeaWj^F4MSgFM+@fJd{|LLM)}_O<{rj z+?*Lm?owq?IzC%U%9EBga~h-cJbIu=#C}XuWN>OLrc%M@Gu~kFEYUi4EC6l#PR2JS zQUkGKrrS#6H7}2l0F@S11DP`@pih0WRkRJl#F;u{c&ZC{^$Z+_*lB)r)-bPgRFE;* zl)@hK4`tEP=P=il02x7-C7p%l=B`vkYjw?YhdJU9!P!jcmY$OtC^12w?vy3<<=tlY zUwHJ_0lgWN9vf>1%WACBD{UT)1qHQSE2%z|JHvP{#INr13jM}oYv_5#xsnv9`)UAO zuwgyV4YZ;O)eSc3(mka6=aRohi!HH@I#xq7kng?Acdg7S4vDJb6cI5fw?2z%3yR+| zU5v@Hm}vy;${cBp&@D=HQ9j7NcFaOYL zj-wV=eYF{|XTkFNM2uz&T8uH~;)^Zo!=KP)EVyH6s9l1~4m}N%XzPpduPg|h-&lL` zAXspR0YMOKd2yO)eMFFJ4?sQ&!`dF&!|niH*!^*Ml##o0M(0*uK9&yzekFi$+mP9s z>W9d%Jb)PtVi&-Ha!o~Iyh@KRuKpQ@)I~L*d`{O8!kRObjO7=n+Gp36fe!66neh+7 zW*l^0tTKjLLzr`x4`_8&on?mjW-PzheTNox8Hg7Nt@*SbE-%kP2hWYmHu#Fn@Q^J(SsPUz*|EgOoZ6byg3ew88UGdZ>9B2Tq=jF72ZaR=4u%1A6Vm{O#?@dD!(#tmR;eP(Fu z{$0O%=Vmua7=Gjr8nY%>ul?w=FJ76O2js&17W_iq2*tb!i{pt#`qZB#im9Rl>?t?0c zicIC}et_4d+CpVPx)i4~$u6N-QX3H77ez z?ZdvXifFk|*F8~L(W$OWM~r`pSk5}#F?j_5u$Obu9lDWIknO^AGu+Blk7!9Sb;NjS zncZA?qtASdNtzQ>z7N871IsPAk^CC?iIL}+{K|F@BuG2>qQ;_RUYV#>hHO(HUPpk@ z(bn~4|F_jiZi}Sad;_7`#4}EmD<1EiIxa48QjUuR?rC}^HRocq`OQPM@aHVKP9E#q zy%6bmHygCpIddPjE}q_DPC`VH_2m;Eey&ZH)E6xGeStOK7H)#+9y!%-Hm|QF6w#A( zIC0Yw%9j$s-#odxG~C*^MZ?M<+&WJ+@?B_QPUyTg9DJGtQN#NIC&-XddRsf3n^AL6 zT@P|H;PvN;ZpL0iv$bRb7|J{0o!Hq+S>_NrH4@coZtBJu#g8#CbR7|#?6uxi8d+$g z87apN>EciJZ`%Zv2**_uiET9Vk{pny&My;+WfGDw4EVL#B!Wiw&M|A8f1A@ z(yFQS6jfbH{b8Z-S7D2?Ixl`j0{+ZnpT=;KzVMLW{B$`N?Gw^Fl0H6lT61%T2AU**!sX0u?|I(yoy&Xveg7XBL&+>n6jd1##6d>TxE*Vj=8lWiG$4=u{1UbAa5QD>5_ z;Te^42v7K6Mmu4IWT6Rnm>oxrl~b<~^e3vbj-GCdHLIB_>59}Ya+~OF68NiH=?}2o zP(X7EN=quQn&)fK>M&kqF|<_*H`}c zk=+x)GU>{Af#vx&s?`UKUsz})g^Pc&?Ka@t5$n$bqf6{r1>#mWx6Ep>9|A}VmWRnowVo`OyCr^fHsf# zQjQ3Ttp7y#iQY8l`zEUW)(@gGQdt(~rkxlkefskT(t%@i8=|p1Y9Dc5bc+z#n$s13 zGJk|V0+&Ekh(F};PJzQKKo+FG@KV8a<$gmNSD;7rd_nRdc%?9)p!|B-@P~kxQG}~B zi|{0}@}zKC(rlFUYp*dO1RuvPC^DQOkX4<+EwvBAC{IZQdYxoq1Za!MW7%p7gGr=j zzWnAq%)^O2$eItftC#TTSArUyL$U54-O7e|)4_7%Q^2tZ^0-d&3J1}qCzR4dWX!)4 zzIEKjgnYgMus^>6uw4Jm8ga6>GBtMjpNRJ6CP~W=37~||gMo_p@GA@#-3)+cVYnU> zE5=Y4kzl+EbEh%dhQokB{gqNDqx%5*qBusWV%!iprn$S!;oN_6E3?0+umADVs4ako z?P+t?m?};gev9JXQ#Q&KBpzkHPde_CGu-y z<{}RRAx=xlv#mVi+Ibrgx~ujW$h{?zPfhz)Kp7kmYS&_|97b&H&1;J-mzrBWAvY} zh8-I8hl_RK2+nnf&}!W0P+>5?#?7>npshe<1~&l_xqKd0_>dl_^RMRq@-Myz&|TKZBj1=Q()) zF{dBjv5)h=&Z)Aevx}+i|7=R9rG^Di!sa)sZCl&ctX4&LScQ-kMncgO(9o6W6)yd< z@Rk!vkja*X_N3H=BavGoR0@u0<}m-7|2v!0+2h~S2Q&a=lTH91OJsvms2MT~ zY=c@LO5i`mLpBd(vh|)I&^A3TQLtr>w=zoyzTd=^f@TPu&+*2MtqE$Avf>l>}V|3-8Fp2hzo3y<)hr_|NO(&oSD z!vEjTWBxbKTiShVl-U{n*B3#)3a8$`{~Pk}J@elZ=>Pqp|MQ}jrGv7KrNcjW%TN_< zZz8kG{#}XoeWf7qY?D)L)8?Q-b@Na&>i=)(@uNo zr;cH98T3$Iau8Hn*@vXi{A@YehxDE2zX~o+RY`)6-X{8~hMpc#C`|8y> zU8Mnv5A0dNCf{Ims*|l-^ z(MRp{qoGohB34|ggDI*p!Aw|MFyJ|v+<+E3brfrI)|+l3W~CQLPbnF@G0)P~Ly!1TJLp}xh8uW`Q+RB-v`MRYZ9Gam3cM%{ zb4Cb*f)0deR~wtNb*8w-LlIF>kc7DAv>T0D(a3@l`k4TFnrO+g9XH7;nYOHxjc4lq zMmaW6qpgAgy)MckYMhl?>sq;-1E)-1llUneeA!ya9KM$)DaNGu57Z5aE>=VST$#vb zFo=uRHr$0M{-ha>h(D_boS4zId;3B|Tpqo|?B?Z@I?G(?&Iei+-{9L_A9=h=Qfn-U z1wIUnQe9!z%_j$F_{rf&`ZFSott09gY~qrf@g3O=Y>vzAnXCyL!@(BqWa)Zqt!#_k zfZHuwS52|&&)aK;CHq9V-t9qt0au{$#6c*R#e5n3rje0hic7c7m{kW$p(_`wB=Gw7 z4k`1Hi;Mc@yA7dp@r~?@rfw)TkjAW++|pkfOG}0N|2guek}j8Zen(!+@7?qt_7ndX zB=BG6WJ31#F3#Vk3=aQr8T)3`{=p9nBHlKzE0I@v`{vJ}h8pd6vby&VgFhzH|q;=aonunAXL6G2y(X^CtAhWr*jI zGjpY@raZDQkg*aMq}Ni6cRF z{oWv}5`nhSAv>usX}m^GHt`f(t8@zHc?K|y5Zi=4G*UG1Sza{$Dpj%X8 zzEXaKT5N6F5j4J|w#qlZP!zS7BT)9b+!ZSJdToqJts1c!)fwih4d31vfb{}W)EgcA zH2pZ^8_k$9+WD2n`6q5XbOy8>3pcYH9 z07eUB+p}YD@AH!}p!iKv><2QF-Y^&xx^PAc1F13A{nUeCDg&{hnix#FiO!fe(^&%Qcux!h znu*S!s$&nnkeotYsDthh1dq(iQrE|#f_=xVgfiiL&-5eAcC-> z5L0l|DVEM$#ulf{bj+Y~7iD)j<~O8CYM8GW)dQGq)!mck)FqoL^X zwNdZb3->hFrbHFm?hLvut-*uK?zXn3q1z|UX{RZ;-WiLoOjnle!xs+W0-8D)kjU#R z+S|A^HkRg$Ij%N4v~k`jyHffKaC~=wg=9)V5h=|kLQ@;^W!o2^K+xG&2n`XCd>OY5Ydi= zgHH=lgy++erK8&+YeTl7VNyVm9-GfONlSlVb3)V9NW5tT!cJ8d7X)!b-$fb!s76{t z@d=Vg-5K_sqHA@Zx-L_}wVnc@L@GL9_K~Zl(h5@AR#FAiKad8~KeWCo@mgXIQ#~u{ zgYFwNz}2b6Vu@CP0XoqJ+dm8px(5W5-Jpis97F`+KM)TuP*X8H@zwiVKDKGVp59pI zifNHZr|B+PG|7|Y<*tqap0CvG7tbR1R>jn70t1X`XJixiMVcHf%Ez*=xm1(CrTSDt z0cle!+{8*Ja&EOZ4@$qhBuKQ$U95Q%rc7tg$VRhk?3=pE&n+T3upZg^ZJc9~c2es% zh7>+|mrmA-p&v}|OtxqmHIBgUxL~^0+cpfkSK2mhh+4b=^F1Xgd2)}U*Yp+H?ls#z zrLxWg_hm}AfK2XYWr!rzW4g;+^^&bW%LmbtRai9f3PjU${r@n`JThy-cphbcwn)rq9{A$Ht`lmYKxOacy z6v2R(?gHhD5@&kB-Eg?4!hAoD7~(h>(R!s1c1Hx#s9vGPePUR|of32bS`J5U5w{F) z>0<^ktO2UHg<0{oxkdOQ;}coZDQph8p6ruj*_?uqURCMTac;>T#v+l1Tc~%^k-Vd@ zkc5y35jVNc49vZpZx;gG$h{%yslDI%Lqga1&&;mN{Ush1c7p>7e-(zp}6E7f-XmJb4nhk zb8zS+{IVbL$QVF8pf8}~kQ|dHJAEATmmnrb_wLG}-yHe>W|A&Y|;muy-d^t^<&)g5SJfaTH@P1%euONny=mxo+C z4N&w#biWY41r8k~468tvuYVh&XN&d#%QtIf9;iVXfWY)#j=l`&B~lqDT@28+Y!0E+MkfC}}H*#(WKKdJJq=O$vNYCb(ZG@p{fJgu;h z21oHQ(14?LeT>n5)s;uD@5&ohU!@wX8w*lB6i@GEH0pM>YTG+RAIWZD;4#F1&F%Jp zXZUml2sH0!lYJT?&sA!qwez6cXzJEd(1ZC~kT5kZSp7(@=H2$Azb_*W&6aA|9iwCL zdX7Q=42;@dspHDwYE?miGX#L^3xD&%BI&fN9^;`v4OjQXPBaBmOF1;#C)8XA(WFlH zycro;DS2?(G&6wkr6rqC>rqDv3nfGw3hmN_9Al>TgvmGsL8_hXx09};l9Ow@)F5@y z#VH5WigLDwZE4nh^7&@g{1FV^UZ%_LJ-s<{HN*2R$OPg@R~Z`c-ET*2}XB@9xvAjrK&hS=f|R8Gr9 zr|0TGOsI7RD+4+2{ZiwdVD@2zmg~g@^D--YL;6UYGSM8i$NbQr4!c7T9rg!8;TM0E zT#@?&S=t>GQm)*ua|?TLT2ktj#`|R<_*FAkOu2Pz$wEc%-=Y9V*$&dg+wIei3b*O8 z2|m$!jJG!J!ZGbbIa!(Af~oSyZV+~M1qGvelMzPNE_%5?c2>;MeeG2^N?JDKjFYCy z7SbPWH-$cWF9~fX%9~v99L!G(wi!PFp>rB!9xj7=Cv|F+7CsGNwY0Q_J%FID%C^CBZQfJ9K(HK%k31j~e#&?hQ zNuD6gRkVckU)v+53-fc} z7ZCzYN-5RG4H7;>>Hg?LU9&5_aua?A0)0dpew1#MMlu)LHe(M;OHjHIUl7|%%)YPo z0cBk;AOY00%Fe6heoN*$(b<)Cd#^8Iu;-2v@>cE-OB$icUF9EEoaC&q8z9}jMTT2I z8`9;jT%z0;dy4!8U;GW{i`)3!c6&oWY`J3669C!tM<5nQFFrFRglU8f)5Op$GtR-3 zn!+SPCw|04sv?%YZ(a7#L?vsdr7ss@WKAw&A*}-1S|9~cL%uA+E~>N6QklFE>8W|% zyX-qAUGTY1hQ-+um`2|&ji0cY*(qN!zp{YpDO-r>jPk*yuVSay<)cUt`t@&FPF_&$ zcHwu1(SQ`I-l8~vYyUxm@D1UEdFJ$f5Sw^HPH7b!9 zzYT3gKMF((N(v0#4f_jPfVZ=ApN^jQJe-X$`A?X+vWjLn_%31KXE*}5_}d8 zw_B1+a#6T1?>M{ronLbHIlEsMf93muJ7AH5h%;i99<~JX^;EAgEB1uHralD*!aJ@F zV2ruuFe9i2Q1C?^^kmVy921eb=tLDD43@-AgL^rQ3IO9%+vi_&R2^dpr}x{bCVPej z7G0-0o64uyWNtr*loIvslyo0%)KSDDKjfThe0hcqs)(C-MH1>bNGBDRTW~scy_{w} zp^aq8Qb!h9Lwielq%C1b8=?Z=&U)ST&PHbS)8Xzjh2DF?d{iAv)Eh)wsUnf>UtXN( zL7=$%YrZ#|^c{MYmhn!zV#t*(jdmYdCpwqpZ{v&L8KIuKn`@IIZfp!uo}c;7J57N` zAxyZ-uA4=Gzl~Ovycz%MW9ZL7N+nRo&1cfNn9(1H5eM;V_4Z_qVann7F>5f>%{rf= zPBZFaV@_Sobl?Fy&KXyzFDV*FIdhS5`Uc~S^Gjo)aiTHgn#<0C=9o-a-}@}xDor;D zZyZ|fvf;+=3MZd>SR1F^F`RJEZo+|MdyJYQAEauKu%WDol~ayrGU3zzbHKsnHKZ*z zFiwUkL@DZ>!*x05ql&EBq@_Vqv83&?@~q5?lVmffQZ+V-=qL+!u4Xs2Z2zdCQ3U7B&QR9_Iggy} z(om{Y9eU;IPe`+p1ifLx-XWh?wI)xU9ik+m#g&pGdB5Bi<`PR*?92lE0+TkRuXI)z z5LP!N2+tTc%cB6B1F-!fj#}>S!vnpgVU~3!*U1ej^)vjUH4s-bd^%B=ItQqDCGbrEzNQi(dJ`J}-U=2{7-d zK8k^Rlq2N#0G?9&1?HSle2vlkj^KWSBYTwx`2?9TU_DX#J+f+qLiZCqY1TXHFxXZqYMuD@RU$TgcnCC{_(vwZ-*uX)~go#%PK z@}2Km_5aQ~(<3cXeJN6|F8X_1@L%@xTzs}$_*E|a^_URF_qcF;Pfhoe?FTFwvjm1o z8onf@OY@jC2tVcMaZS;|T!Ks(wOgPpRzRnFS-^RZ4E!9dsnj9sFt609a|jJbb1Dt@ z<=Gal2jDEupxUSwWu6zp<<&RnAA;d&4gKVG0iu6g(DsST(4)z6R)zDpfaQ}v{5ARt zyhwvMtF%b-YazR5XLz+oh=mn;y-Mf2a8>7?2v8qX;19y?b>Z5laGHvzH;Nu9S`B8} zI)qN$GbXIQ1VL3lnof^6TS~rvPVg4V?Dl2Bb*K2z4E{5vy<(@@K_cN@U>R!>aUIRnb zL*)=787*cs#zb31zBC49x$`=fkQbMAef)L2$dR{)6BAz!t5U_B#1zZG`^neKSS22oJ#5B=gl%U=WeqL9REF2g zZnfCb0?quf?Ztj$VXvDSWoK`0L=Zxem2q}!XWLoT-kYMOx)!7fcgT35uC~0pySEme z`{wGWTkGr7>+Kb^n;W?BZH6ZP(9tQX%-7zF>vc2}LuWDI(9kh1G#7B99r4x6;_-V+k&c{nPUrR zAXJGRiMe~aup{0qzmLNjS_BC4cB#sXjckx{%_c&^xy{M61xEb>KW_AG5VFXUOjAG4 z^>Qlm9A#1N{4snY=(AmWzatb!ngqiqPbBZ7>Uhb3)dTkSGcL#&SH>iMO-IJBPua`u zo)LWZ>=NZLr758j{%(|uQuZ)pXq_4c!!>s|aDM9#`~1bzK3J1^^D#<2bNCccH7~-X}Ggi!pIIF>uFx%aPARGQsnC8ZQc8lrQ5o~smqOg>Ti^GNme94*w z)JZy{_{#$jxGQ&`M z!OMvZMHR>8*^>eS%o*6hJwn!l8VOOjZQJvh)@tnHVW&*GYPuxqXw}%M!(f-SQf`=L z5;=5w2;%82VMH6Xi&-K3W)o&K^+vJCepWZ-rW%+Dc6X3(){z$@4zjYxQ|}8UIojeC zYZpQ1dU{fy=oTr<4VX?$q)LP}IUmpiez^O&N3E_qPpchGTi5ZM6-2ScWlQq%V&R2Euz zO|Q0Hx>lY1Q1cW5xHv5!0OGU~PVEqSuy#fD72d#O`N!C;o=m+YioGu-wH2k6!t<~K zSr`E=W9)!g==~x9VV~-8{4ZN9{~-A9zJpRe%NGg$+MDuI-dH|b@BD)~>pPCGUNNzY zMDg||0@XGQgw`YCt5C&A{_+J}mvV9Wg{6V%2n#YSRN{AP#PY?1FF1#|vO_%e+#`|2*~wGAJaeRX6=IzFNeWhz6gJc8+(03Ph4y6ELAm=AkN7TOgMUEw*N{= z_)EIDQx5q22oUR+_b*tazu9+pX|n1c*IB-}{DqIj z-?E|ks{o3AGRNb;+iKcHkZvYJvFsW&83RAPs1Oh@IWy%l#5x2oUP6ZCtv+b|q>jsf zZ_9XO;V!>n`UxH1LvH8)L4?8raIvasEhkpQoJ`%!5rBs!0Tu(s_D{`4opB;57)pkX z4$A^8CsD3U5*!|bHIEqsn~{q+Ddj$ME@Gq4JXtgVz&7l{Ok!@?EA{B3P~NAqb9)4? zkQo30A^EbHfQ@87G5&EQTd`frrwL)&Yw?%-W@uy^Gn23%j?Y!Iea2xw<-f;esq zf%w5WN@E1}zyXtYv}}`U^B>W`>XPmdLj%4{P298|SisrE;7HvXX;A}Ffi8B#3Lr;1 zHt6zVb`8{#+e$*k?w8|O{Uh|&AG}|DG1PFo1i?Y*cQm$ZwtGcVgMwtBUDa{~L1KT-{jET4w60>{KZ27vXrHJ;fW{6| z=|Y4!&UX020wU1>1iRgB@Q#m~1^Z^9CG1LqDhYBrnx%IEdIty z!46iOoKlKs)c}newDG)rWUikD%j`)p z_w9Ph&e40=(2eBy;T!}*1p1f1SAUDP9iWy^u^Ubdj21Kn{46;GR+hwLO=4D11@c~V zI8x&(D({K~Df2E)Nx_yQvYfh4;MbMJ@Z}=Dt3_>iim~QZ*hZIlEs0mEb z_54+&*?wMD`2#vsQRN3KvoT>hWofI_Vf(^C1ff-Ike@h@saEf7g}<9T`W;HAne-Nd z>RR+&SP35w)xKn8^U$7))PsM!jKwYZ*RzEcG-OlTrX3}9a{q%#Un5E5W{{hp>w~;` zGky+3(vJvQyGwBo`tCpmo0mo((?nM8vf9aXrrY1Ve}~TuVkB(zeds^jEfI}xGBCM2 zL1|#tycSaWCurP+0MiActG3LCas@_@tao@(R1ANlwB$4K53egNE_;!&(%@Qo$>h`^1S_!hN6 z)vZtG$8fN!|BXBJ=SI>e(LAU(y(i*PHvgQ2llulxS8>qsimv7yL}0q_E5WiAz7)(f zC(ahFvG8&HN9+6^jGyLHM~$)7auppeWh_^zKk&C_MQ~8;N??OlyH~azgz5fe^>~7F zl3HnPN3z-kN)I$4@`CLCMQx3sG~V8hPS^}XDXZrQA>}mQPw%7&!sd(Pp^P=tgp-s^ zjl}1-KRPNWXgV_K^HkP__SR`S-|OF0bR-N5>I%ODj&1JUeAQ3$9i;B~$S6}*^tK?= z**%aCiH7y?xdY?{LgVP}S0HOh%0%LI$wRx;$T|~Y8R)Vdwa}kGWv8?SJVm^>r6+%I z#lj1aR94{@MP;t-scEYQWc#xFA30^}?|BeX*W#9OL;Q9#WqaaM546j5j29((^_8Nu z4uq}ESLr~r*O7E7$D{!k9W>`!SLoyA53i9QwRB{!pHe8um|aDE`Cg0O*{jmor)^t)3`>V>SWN-2VJcFmj^1?~tT=JrP`fVh*t zXHarp=8HEcR#vFe+1a%XXuK+)oFs`GDD}#Z+TJ}Ri`FvKO@ek2ayn}yaOi%(8p%2$ zpEu)v0Jym@f}U|-;}CbR=9{#<^z28PzkkTNvyKvJDZe+^VS2bES3N@Jq!-*}{oQlz z@8bgC_KnDnT4}d#&Cpr!%Yb?E!brx0!eVOw~;lLwUoz#Np%d$o%9scc3&zPm`%G((Le|6o1 zM(VhOw)!f84zG^)tZ1?Egv)d8cdNi+T${=5kV+j;Wf%2{3g@FHp^Gf*qO0q!u$=m9 zCaY`4mRqJ;FTH5`a$affE5dJrk~k`HTP_7nGTY@B9o9vvnbytaID;^b=Tzp7Q#DmD zC(XEN)Ktn39z5|G!wsVNnHi) z%^q94!lL|hF`IijA^9NR0F$@h7k5R^ljOW(;Td9grRN0Mb)l_l7##{2nPQ@?;VjXv zaLZG}yuf$r$<79rVPpXg?6iiieX|r#&`p#Con2i%S8*8F}(E) zI5E6c3tG*<;m~6>!&H!GJ6zEuhH7mkAzovdhLy;)q z{H2*8I^Pb}xC4s^6Y}6bJvMu=8>g&I)7!N!5QG$xseeU#CC?ZM-TbjsHwHgDGrsD= z{%f;@Sod+Ch66Ko2WF~;Ty)v>&x^aovCbCbD7>qF*!?BXmOV3(s|nxsb*Lx_2lpB7 zokUnzrk;P=T-&kUHO}td+Zdj!3n&NR?K~cRU zAXU!DCp?51{J4w^`cV#ye}(`SQhGQkkMu}O3M*BWt4UsC^jCFUy;wTINYmhD$AT;4 z?Xd{HaJjP`raZ39qAm;%beDbrLpbRf(mkKbANan7XsL>_pE2oo^$TgdidjRP!5-`% zv0d!|iKN$c0(T|L0C~XD0aS8t{*&#LnhE;1Kb<9&=c2B+9JeLvJr*AyyRh%@jHej=AetOMSlz^=!kxX>>B{2B1uIrQyfd8KjJ+DBy!h)~*(!|&L4^Q_07SQ~E zcemVP`{9CwFvPFu7pyVGCLhH?LhEVb2{7U+Z_>o25#+3<|8%1T^5dh}*4(kfJGry} zm%r#hU+__Z;;*4fMrX=Bkc@7|v^*B;HAl0((IBPPii%X9+u3DDF6%bI&6?Eu$8&aWVqHIM7mK6?Uvq$1|(-T|)IV<>e?!(rY zqkmO1MRaLeTR=)io(0GVtQT@s6rN%C6;nS3@eu;P#ry4q;^O@1ZKCJyp_Jo)Ty^QW z+vweTx_DLm{P-XSBj~Sl<%_b^$=}odJ!S2wAcxenmzFGX1t&Qp8Vxz2VT`uQsQYtdn&_0xVivIcxZ_hnrRtwq4cZSj1c-SG9 z7vHBCA=fd0O1<4*=lu$6pn~_pVKyL@ztw1swbZi0B?spLo56ZKu5;7ZeUml1Ws1?u zqMf1p{5myAzeX$lAi{jIUqo1g4!zWLMm9cfWcnw`k6*BR^?$2(&yW?>w;G$EmTA@a z6?y#K$C~ZT8+v{87n5Dm&H6Pb_EQ@V0IWmG9cG=O;(;5aMWWrIPzz4Q`mhK;qQp~a z+BbQrEQ+w{SeiuG-~Po5f=^EvlouB@_|4xQXH@A~KgpFHrwu%dwuCR)=B&C(y6J4J zvoGk9;lLs9%iA-IJGU#RgnZZR+@{5lYl8(e1h6&>Vc_mvg0d@);X zji4T|n#lB!>pfL|8tQYkw?U2bD`W{na&;*|znjmalA&f;*U++_aBYerq;&C8Kw7mI z7tsG*?7*5j&dU)Lje;^{D_h`%(dK|pB*A*1(Jj)w^mZ9HB|vGLkF1GEFhu&rH=r=8 zMxO42e{Si6$m+Zj`_mXb&w5Q(i|Yxyg?juUrY}78uo@~3v84|8dfgbPd0iQJRdMj< zncCNGdMEcsxu#o#B5+XD{tsg*;j-eF8`mp~K8O1J!Z0+>0=7O=4M}E?)H)ENE;P*F z$Ox?ril_^p0g7xhDUf(q652l|562VFlC8^r8?lQv;TMvn+*8I}&+hIQYh2 z1}uQQaag&!-+DZ@|C+C$bN6W;S-Z@)d1|en+XGvjbOxCa-qAF*LA=6s(Jg+g;82f$ z(Vb)8I)AH@cdjGFAR5Rqd0wiNCu!xtqWbcTx&5kslzTb^7A78~Xzw1($UV6S^VWiP zFd{Rimd-0CZC_Bu(WxBFW7+k{cOW7DxBBkJdJ;VsJ4Z@lERQr%3eVv&$%)b%<~ zCl^Y4NgO}js@u{|o~KTgH}>!* z_iDNqX2(As7T0xivMH|3SC1ivm8Q}6Ffcd7owUKN5lHAtzMM4<0v+ykUT!QiowO;`@%JGv+K$bBx@*S7C8GJVqQ_K>12}M`f_Ys=S zKFh}HM9#6Izb$Y{wYzItTy+l5U2oL%boCJn?R3?jP@n$zSIwlmyGq30Cw4QBO|14` zW5c);AN*J3&eMFAk$SR~2k|&+&Bc$e>s%c{`?d~85S-UWjA>DS5+;UKZ}5oVa5O(N zqqc@>)nee)+4MUjH?FGv%hm2{IlIF-QX}ym-7ok4Z9{V+ZHVZQl$A*x!(q%<2~iVv znUa+BX35&lCb#9VE-~Y^W_f;Xhl%vgjwdjzMy$FsSIj&ok}L+X`4>J=9BkN&nu^E*gbhj3(+D>C4E z@Fwq_=N)^bKFSHTzZk?-gNU$@l}r}dwGyh_fNi=9b|n}J>&;G!lzilbWF4B}BBq4f zYIOl?b)PSh#XTPp4IS5ZR_2C!E)Z`zH0OW%4;&~z7UAyA-X|sh9@~>cQW^COA9hV4 zXcA6qUo9P{bW1_2`eo6%hgbN%(G-F1xTvq!sc?4wN6Q4`e9Hku zFwvlAcRY?6h^Fj$R8zCNEDq8`=uZB8D-xn)tA<^bFFy}4$vA}Xq0jAsv1&5!h!yRA zU()KLJya5MQ`q&LKdH#fwq&(bNFS{sKlEh_{N%{XCGO+po#(+WCLmKW6&5iOHny>g z3*VFN?mx!16V5{zyuMWDVP8U*|BGT$(%IO|)?EF|OI*sq&RovH!N%=>i_c?K*A>>k zyg1+~++zY4Q)J;VWN0axhoIKx;l&G$gvj(#go^pZskEVj8^}is3Jw26LzYYVos0HX zRPvmK$dVxM8(Tc?pHFe0Z3uq){{#OK3i-ra#@+;*=ui8)y6hsRv z4Fxx1c1+fr!VI{L3DFMwXKrfl#Q8hfP@ajgEau&QMCxd{g#!T^;ATXW)nUg&$-n25 zruy3V!!;{?OTobo|0GAxe`Acn3GV@W=&n;~&9 zQM>NWW~R@OYORkJAo+eq1!4vzmf9K%plR4(tB@TR&FSbDoRgJ8qVcH#;7lQub*nq&?Z>7WM=oeEVjkaG zT#f)=o!M2DO5hLR+op>t0CixJCIeXH*+z{-XS|%jx)y(j&}Wo|3!l7{o)HU3m7LYyhv*xF&tq z%IN7N;D4raue&&hm0xM=`qv`+TK@;_xAcGKuK(2|75~ar2Yw)geNLSmVxV@x89bQu zpViVKKnlkwjS&&c|-X6`~xdnh}Ps)Hs z4VbUL^{XNLf7_|Oi>tA%?SG5zax}esF*FH3d(JH^Gvr7Rp*n=t7frH!U;!y1gJB^i zY_M$KL_}mW&XKaDEi9K-wZR|q*L32&m+2n_8lq$xRznJ7p8}V>w+d@?uB!eS3#u<} zIaqi!b!w}a2;_BfUUhGMy#4dPx>)_>yZ`ai?Rk`}d0>~ce-PfY-b?Csd(28yX22L% zI7XI>OjIHYTk_@Xk;Gu^F52^Gn6E1&+?4MxDS2G_#PQ&yXPXP^<-p|2nLTb@AAQEY zI*UQ9Pmm{Kat}wuazpjSyXCdnrD&|C1c5DIb1TnzF}f4KIV6D)CJ!?&l&{T)e4U%3HTSYqsQ zo@zWB1o}ceQSV)<4G<)jM|@@YpL+XHuWsr5AYh^Q{K=wSV99D~4RRU52FufmMBMmd z_H}L#qe(}|I9ZyPRD6kT>Ivj&2Y?qVZq<4bG_co_DP`sE*_Xw8D;+7QR$Uq(rr+u> z8bHUWbV19i#)@@G4bCco@Xb<8u~wVDz9S`#k@ciJtlu@uP1U0X?yov8v9U3VOig2t zL9?n$P3=1U_Emi$#slR>N5wH-=J&T=EdUHA}_Z zZIl3nvMP*AZS9{cDqFanrA~S5BqxtNm9tlu;^`)3X&V4tMAkJ4gEIPl= zoV!Gyx0N{3DpD@)pv^iS*dl2FwANu;1;%EDl}JQ7MbxLMAp>)UwNwe{=V}O-5C*>F zu?Ny+F64jZn<+fKjF01}8h5H_3pey|;%bI;SFg$w8;IC<8l|3#Lz2;mNNik6sVTG3 z+Su^rIE#40C4a-587$U~%KedEEw1%r6wdvoMwpmlXH$xPnNQN#f%Z7|p)nC>WsuO= z4zyqapLS<8(UJ~Qi9d|dQijb_xhA2)v>la)<1md5s^R1N&PiuA$^k|A<+2C?OiHbj z>Bn$~t)>Y(Zb`8hW7q9xQ=s>Rv81V+UiuZJc<23HplI88isqRCId89fb`Kt|CxVIg znWcwprwXnotO>3s&Oypkte^9yJjlUVVxSe%_xlzmje|mYOVPH^vjA=?6xd0vaj0Oz zwJ4OJNiFdnHJX3rw&inskjryukl`*fRQ#SMod5J|KroJRsVXa5_$q7whSQ{gOi*s0 z1LeCy|JBWRsDPn7jCb4s(p|JZiZ8+*ExC@Vj)MF|*Vp{B(ziccSn`G1Br9bV(v!C2 z6#?eqpJBc9o@lJ#^p-`-=`4i&wFe>2)nlPK1p9yPFzJCzBQbpkcR>={YtamIw)3nt z(QEF;+)4`>8^_LU)_Q3 zC5_7lgi_6y>U%m)m@}Ku4C}=l^J=<<7c;99ec3p{aR+v=diuJR7uZi%aQv$oP?dn?@6Yu_+*^>T0ptf(oobdL;6)N-I!TO`zg^Xbv3#L0I~sn@WGk-^SmPh5>W+LB<+1PU}AKa?FCWF|qMNELOgdxR{ zbqE7@jVe+FklzdcD$!(A$&}}H*HQFTJ+AOrJYnhh}Yvta(B zQ_bW4Rr;R~&6PAKwgLWXS{Bnln(vUI+~g#kl{r+_zbngT`Y3`^Qf=!PxN4IYX#iW4 zucW7@LLJA9Zh3(rj~&SyN_pjO8H&)|(v%!BnMWySBJV=eSkB3YSTCyIeJ{i;(oc%_hk{$_l;v>nWSB)oVeg+blh=HB5JSlG_r7@P z3q;aFoZjD_qS@zygYqCn=;Zxjo!?NK!%J$ z52lOP`8G3feEj+HTp@Tnn9X~nG=;tS+z}u{mQX_J0kxtr)O30YD%oo)L@wy`jpQYM z@M>Me=95k1p*FW~rHiV1CIfVc{K8r|#Kt(ApkXKsDG$_>76UGNhHExFCw#Ky9*B-z zNq2ga*xax!HMf_|Vp-86r{;~YgQKqu7%szk8$hpvi_2I`OVbG1doP(`gn}=W<8%Gn z%81#&WjkH4GV;4u43EtSW>K_Ta3Zj!XF?;SO3V#q=<=>Tc^@?A`i;&`-cYj|;^ zEo#Jl5zSr~_V-4}y8pnufXLa80vZY4z2ko7fj>DR)#z=wWuS1$$W!L?(y}YC+yQ|G z@L&`2upy3f>~*IquAjkVNU>}c10(fq#HdbK$~Q3l6|=@-eBbo>B9(6xV`*)sae58*f zym~RRVx;xoCG3`JV`xo z!lFw)=t2Hy)e!IFs?0~7osWk(d%^wxq&>_XD4+U#y&-VF%4z?XH^i4w`TxpF{`XhZ z%G}iEzf!T(l>g;W9<~K+)$g!{UvhW{E0Lis(S^%I8OF&%kr!gJ&fMOpM=&=Aj@wuL zBX?*6i51Qb$uhkwkFYkaD_UDE+)rh1c;(&Y=B$3)J&iJfQSx!1NGgPtK!$c9OtJuu zX(pV$bfuJpRR|K(dp@^j}i&HeJOh@|7lWo8^$*o~Xqo z5Sb+!EtJ&e@6F+h&+_1ETbg7LfP5GZjvIUIN3ibCOldAv z)>YdO|NH$x7AC8dr=<2ekiY1%fN*r~e5h6Yaw<{XIErujKV~tiyrvV_DV0AzEknC- zR^xKM3i<1UkvqBj3C{wDvytOd+YtDSGu!gEMg+!&|8BQrT*|p)(dwQLEy+ zMtMzij3zo40)CA!BKZF~yWg?#lWhqD3@qR)gh~D{uZaJO;{OWV8XZ_)J@r3=)T|kt zUS1pXr6-`!Z}w2QR7nP%d?ecf90;K_7C3d!UZ`N(TZoWNN^Q~RjVhQG{Y<%E1PpV^4 z-m-K+$A~-+VDABs^Q@U*)YvhY4Znn2^w>732H?NRK(5QSS$V@D7yz2BVX4)f5A04~$WbxGOam22>t&uD)JB8-~yiQW6ik;FGblY_I>SvB_z2?PS z*Qm&qbKI{H1V@YGWzpx`!v)WeLT02};JJo*#f$a*FH?IIad-^(;9XC#YTWN6;Z6+S zm4O1KH=#V@FJw7Pha0!9Vb%ZIM$)a`VRMoiN&C|$YA3~ZC*8ayZRY^fyuP6$n%2IU z$#XceYZeqLTXw(m$_z|33I$B4k~NZO>pP6)H_}R{E$i%USGy{l{-jOE;%CloYPEU+ zRFxOn4;7lIOh!7abb23YKD+_-?O z0FP9otcAh+oSj;=f#$&*ExUHpd&e#bSF%#8*&ItcL2H$Sa)?pt0Xtf+t)z$_u^wZi z44oE}r4kIZGy3!Mc8q$B&6JqtnHZ>Znn!Zh@6rgIu|yU+zG8q`q9%B18|T|oN3zMq z`l&D;U!OL~%>vo&q0>Y==~zLiCZk4v%s_7!9DxQ~id1LLE93gf*gg&2$|hB#j8;?3 z5v4S;oM6rT{Y;I+#FdmNw z){d%tNM<<#GN%n9ox7B=3#;u7unZ~tLB_vRZ52a&2=IM)2VkXm=L+Iqq~uk#Dug|x z>S84e+A7EiOY5lj*!q?6HDkNh~0g;0Jy(al!ZHHDtur9T$y-~)94HelX1NHjXWIM7UAe}$?jiz z9?P4`I0JM=G5K{3_%2jPLC^_Mlw?-kYYgb7`qGa3@dn|^1fRMwiyM@Ch z;CB&o7&&?c5e>h`IM;Wnha0QKnEp=$hA8TJgR-07N~U5(>9vJzeoFsSRBkDq=x(YgEMpb=l4TDD`2 zwVJpWGTA_u7}?ecW7s6%rUs&NXD3+n;jB86`X?8(l3MBo6)PdakI6V6a}22{)8ilT zM~T*mU}__xSy|6XSrJ^%lDAR3Lft%+yxC|ZUvSO_nqMX!_ul3;R#*{~4DA=h$bP)%8Yv9X zyp><|e8=_ttI}ZAwOd#dlnSjck#6%273{E$kJuCGu=I@O)&6ID{nWF5@gLb16sj|&Sb~+du4e4O_%_o`Ix4NRrAsyr1_}MuP94s>de8cH-OUkVPk3+K z&jW)It9QiU-ti~AuJkL`XMca8Oh4$SyJ=`-5WU<{cIh+XVH#e4d&zive_UHC!pN>W z3TB;Mn5i)9Qn)#6@lo4QpI3jFYc0~+jS)4AFz8fVC;lD^+idw^S~Qhq>Tg(!3$yLD zzktzoFrU@6s4wwCMz}edpF5i5Q1IMmEJQHzp(LAt)pgN3&O!&d?3W@6U4)I^2V{;- z6A(?zd93hS*uQmnh4T)nHnE{wVhh(=MMD(h(P4+^p83Om6t<*cUW>l(qJzr%5vp@K zN27ka(L{JX=1~e2^)F^i=TYj&;<7jyUUR2Bek^A8+3Up*&Xwc{)1nRR5CT8vG>ExV zHnF3UqXJOAno_?bnhCX-&kwI~Ti8t4`n0%Up>!U`ZvK^w2+0Cs-b9%w%4`$+To|k= zKtgc&l}P`*8IS>8DOe?EB84^kx4BQp3<7P{Pq}&p%xF_81pg!l2|u=&I{AuUgmF5n zJQCTLv}%}xbFGYtKfbba{CBo)lWW%Z>i(_NvLhoQZ*5-@2l&x>e+I~0Nld3UI9tdL zRzu8}i;X!h8LHVvN?C+|M81e>Jr38%&*9LYQec9Ax>?NN+9(_>XSRv&6hlCYB`>Qm z1&ygi{Y()OU4@D_jd_-7vDILR{>o|7-k)Sjdxkjgvi{@S>6GqiF|o`*Otr;P)kLHN zZkpts;0zw_6;?f(@4S1FN=m!4^mv~W+lJA`&7RH%2$)49z0A+8@0BCHtj|yH--AEL z0tW6G%X-+J+5a{5*WKaM0QDznf;V?L5&uQw+yegDNDP`hA;0XPYc6e0;Xv6|i|^F2WB)Z$LR|HR4 zTQsRAby9(^Z@yATyOgcfQw7cKyr^3Tz7lc7+JEwwzA7)|2x+PtEb>nD(tpxJQm)Kn zW9K_*r!L%~N*vS8<5T=iv|o!zTe9k_2jC_j*7ik^M_ zaf%k{WX{-;0*`t`G!&`eW;gChVXnJ-Rn)To8vW-?>>a%QU1v`ZC=U)f8iA@%JG0mZ zDqH;~mgBnrCP~1II<=V9;EBL)J+xzCoiRBaeH&J6rL!{4zIY8tZka?_FBeQeNO3q6 zyG_alW54Ba&wQf{&F1v-r1R6ID)PTsqjIBc+5MHkcW5Fnvi~{-FjKe)t1bl}Y;z@< z=!%zvpRua>>t_x}^}z0<7MI!H2v6|XAyR9!t50q-A)xk0nflgF4*OQlCGK==4S|wc zRMsSscNhRzHMBU8TdcHN!q^I}x0iXJ%uehac|Zs_B$p@CnF)HeXPpB_Za}F{<@6-4 zl%kml@}kHQ(ypD8FsPJ2=14xXJE|b20RUIgs!2|R3>LUMGF6X*B_I|$`Qg=;zm7C z{mEDy9dTmPbued7mlO@phdmAmJ7p@GR1bjCkMw6*G7#4+`k>fk1czdJUB!e@Q(~6# zwo%@p@V5RL0ABU2LH7Asq^quDUho@H>eTZH9f*no9fY0T zD_-9px3e}A!>>kv5wk91%C9R1J_Nh!*&Kk$J3KNxC}c_@zlgpJZ+5L)Nw|^p=2ue}CJtm;uj*Iqr)K})kA$xtNUEvX;4!Px*^&9T_`IN{D z{6~QY=Nau6EzpvufB^hflc#XIsSq0Y9(nf$d~6ZwK}fal92)fr%T3=q{0mP-EyP_G z)UR5h@IX}3Qll2b0oCAcBF>b*@Etu*aTLPU<%C>KoOrk=x?pN!#f_Og-w+;xbFgjQ zXp`et%lDBBh~OcFnMKMUoox0YwBNy`N0q~bSPh@+enQ=4RUw1) zpovN`QoV>vZ#5LvC;cl|6jPr}O5tu!Ipoyib8iXqy}TeJ;4+_7r<1kV0v5?Kv>fYp zg>9L`;XwXa&W7-jf|9~uP2iyF5`5AJ`Q~p4eBU$MCC00`rcSF>`&0fbd^_eqR+}mK z4n*PMMa&FOcc)vTUR zlDUAn-mh`ahi_`f`=39JYTNVjsTa_Y3b1GOIi)6dY)D}xeshB0T8Eov5%UhWd1)u}kjEQ|LDo{tqKKrYIfVz~@dp!! zMOnah@vp)%_-jDTUG09l+;{CkDCH|Q{NqX*uHa1YxFShy*1+;J`gywKaz|2Q{lG8x zP?KBur`}r`!WLKXY_K;C8$EWG>jY3UIh{+BLv0=2)KH%P}6xE2kg)%(-uA6lC?u8}{K(#P*c zE9C8t*u%j2r_{;Rpe1A{9nNXU;b_N0vNgyK!EZVut~}+R2rcbsHilqsOviYh-pYX= zHw@53nlmwYI5W5KP>&`dBZe0Jn?nAdC^HY1wlR6$u^PbpB#AS&5L6zqrXN&7*N2Q` z+Rae1EwS)H=aVSIkr8Ek^1jy2iS2o7mqm~Mr&g5=jjt7VxwglQ^`h#Mx+x2v|9ZAwE$i_9918MjJxTMr?n!bZ6n$}y11u8I9COTU`Z$Fi z!AeAQLMw^gp_{+0QTEJrhL424pVDp%wpku~XRlD3iv{vQ!lAf!_jyqd_h}+Tr1XG| z`*FT*NbPqvHCUsYAkFnM`@l4u_QH&bszpUK#M~XLJt{%?00GXY?u_{gj3Hvs!=N(I z(=AuWPijyoU!r?aFTsa8pLB&cx}$*%;K$e*XqF{~*rA-qn)h^!(-;e}O#B$|S~c+U zN4vyOK0vmtx$5K!?g*+J@G1NmlEI=pyZXZ69tAv=@`t%ag_Hk{LP~OH9iE)I= zaJ69b4kuCkV0V zo(M0#>phpQ_)@j;h%m{-a*LGi(72TP)ws2w*@4|C-3+;=5DmC4s7Lp95%n%@Ko zfdr3-a7m*dys9iIci$A=4NPJ`HfJ;hujLgU)ZRuJI`n;Pw|yksu!#LQnJ#dJysgNb z@@qwR^wrk(jbq4H?d!lNyy72~Dnn87KxsgQ!)|*m(DRM+eC$wh7KnS-mho3|KE)7h zK3k;qZ;K1Lj6uEXLYUYi)1FN}F@-xJ z@@3Hb84sl|j{4$3J}aTY@cbX@pzB_qM~APljrjju6P0tY{C@ zpUCOz_NFmALMv1*blCcwUD3?U6tYs+N%cmJ98D%3)%)Xu^uvzF zS5O!sc#X6?EwsYkvPo6A%O8&y8sCCQH<%f2togVwW&{M;PR!a(ZT_A+jVAbf{@5kL zB@Z(hb$3U{T_}SKA_CoQVU-;j>2J=L#lZ~aQCFg-d<9rzs$_gO&d5N6eFSc z1ml8)P*FSi+k@!^M9nDWR5e@ATD8oxtDu=36Iv2!;dZzidIS(PCtEuXAtlBb1;H%Z zwnC^Ek*D)EX4#Q>R$$WA2sxC_t(!!6Tr?C#@{3}n{<^o;9id1RA&-Pig1e-2B1XpG zliNjgmd3c&%A}s>qf{_j#!Z`fu0xIwm4L0)OF=u(OEmp;bLCIaZX$&J_^Z%4Sq4GZ zPn6sV_#+6pJmDN_lx@1;Zw6Md_p0w9h6mHtzpuIEwNn>OnuRSC2=>fP^Hqgc)xu^4 z<3!s`cORHJh#?!nKI`Et7{3C27+EuH)Gw1f)aoP|B3y?fuVfvpYYmmukx0ya-)TQX zR{ggy5cNf4X|g)nl#jC9p>7|09_S7>1D2GTRBUTW zAkQ=JMRogZqG#v;^=11O6@rPPwvJkr{bW-Qg8`q8GoD#K`&Y+S#%&B>SGRL>;ZunM@49!}Uy zN|bBCJ%sO;@3wl0>0gbl3L@1^O60ONObz8ZI7nder>(udj-jt`;yj^nTQ$L9`OU9W zX4alF#$|GiR47%x@s&LV>2Sz2R6?;2R~5k6V>)nz!o_*1Y!$p>BC5&?hJg_MiE6UBy>RkVZj`9UWbRkN-Hk!S`=BS3t3uyX6)7SF#)71*}`~Ogz z1rap5H6~dhBJ83;q-Y<5V35C2&F^JI-it(=5D#v!fAi9p#UwV~2tZQI+W(Dv?1t9? zfh*xpxxO{-(VGB>!Q&0%^YW_F!@aZS#ucP|YaD#>wd1Fv&Z*SR&mc;asi}1G) z_H>`!akh-Zxq9#io(7%;a$)w+{QH)Y$?UK1Dt^4)up!Szcxnu}kn$0afcfJL#IL+S z5gF_Y30j;{lNrG6m~$Ay?)*V9fZuU@3=kd40=LhazjFrau>(Y>SJNtOz>8x_X-BlA zIpl{i>OarVGj1v(4?^1`R}aQB&WCRQzS~;7R{tDZG=HhgrW@B`W|#cdyj%YBky)P= zpxuOZkW>S6%q7U{VsB#G(^FMsH5QuGXhb(sY+!-R8Bmv6Sx3WzSW<1MPPN1!&PurYky(@`bP9tz z52}LH9Q?+FF5jR6-;|+GVdRA!qtd;}*-h&iIw3Tq3qF9sDIb1FFxGbo&fbG5n8$3F zyY&PWL{ys^dTO}oZ#@sIX^BKW*bon=;te9j5k+T%wJ zNJtoN1~YVj4~YRrlZl)b&kJqp+Z`DqT!la$x&&IxgOQw#yZd-nBP3!7FijBXD|IsU8Zl^ zc6?MKpJQ+7ka|tZQLfchD$PD|;K(9FiLE|eUZX#EZxhG!S-63C$jWX1Yd!6-Yxi-u zjULIr|0-Q%D9jz}IF~S%>0(jOqZ(Ln<$9PxiySr&2Oic7vb<8q=46)Ln%Z|<*z5&> z3f~Zw@m;vR(bESB<=Jqkxn(=#hQw42l(7)h`vMQQTttz9XW6^|^8EK7qhju4r_c*b zJIi`)MB$w@9epwdIfnEBR+?~);yd6C(LeMC& zn&&N*?-g&BBJcV;8&UoZi4Lmxcj16ojlxR~zMrf=O_^i1wGb9X-0@6_rpjPYemIin zmJb+;lHe;Yp=8G)Q(L1bzH*}I>}uAqhj4;g)PlvD9_e_ScR{Ipq|$8NvAvLD8MYr}xl=bU~)f%B3E>r3Bu9_t|ThF3C5~BdOve zEbk^r&r#PT&?^V1cb{72yEWH}TXEE}w>t!cY~rA+hNOTK8FAtIEoszp!qqptS&;r$ zaYV-NX96-h$6aR@1xz6_E0^N49mU)-v#bwtGJm)ibygzJ8!7|WIrcb`$XH~^!a#s& z{Db-0IOTFq#9!^j!n_F}#Z_nX{YzBK8XLPVmc&X`fT7!@$U-@2KM9soGbmOSAmqV z{nr$L^MBo_u^Joyf0E^=eo{Rt0{{e$IFA(#*kP@SQd6lWT2-#>` zP1)7_@IO!9lk>Zt?#CU?cuhiLF&)+XEM9B)cS(gvQT!X3`wL*{fArTS;Ak`J<84du zALKPz4}3nlG8Fo^MH0L|oK2-4xIY!~Oux~1sw!+It)&D3p;+N8AgqKI`ld6v71wy8I!eP0o~=RVcFQR2Gr(eP_JbSytoQ$Yt}l*4r@A8Me94y z8cTDWhqlq^qoAhbOzGBXv^Wa4vUz$(7B!mX`T=x_ueKRRDfg&Uc-e1+z4x$jyW_Pm zp?U;-R#xt^Z8Ev~`m`iL4*c#65Nn)q#=Y0l1AuD&+{|8-Gsij3LUZXpM0Bx0u7WWm zH|%yE@-#XEph2}-$-thl+S;__ciBxSSzHveP%~v}5I%u!z_l_KoW{KRx2=eB33umE zIYFtu^5=wGU`Jab8#}cnYry@9p5UE#U|VVvx_4l49JQ;jQdp(uw=$^A$EA$LM%vmE zvdEOaIcp5qX8wX{mYf0;#51~imYYPn4=k&#DsKTxo{_Mg*;S495?OBY?#gv=edYC* z^O@-sd-qa+U24xvcbL0@C7_6o!$`)sVr-jSJE4XQUQ$?L7}2(}Eixqv;L8AdJAVqc zq}RPgpnDb@E_;?6K58r3h4-!4rT4Ab#rLHLX?eMOfluJk=3i1@Gt1i#iA=O`M0@x! z(HtJP9BMHXEzuD93m|B&woj0g6T?f#^)>J>|I4C5?Gam>n9!8CT%~aT;=oco5d6U8 zMXl(=W;$ND_8+DD*?|5bJ!;8ebESXMUKBAf7YBwNVJibGaJ*(2G`F%wx)grqVPjudiaq^Kl&g$8A2 zWMxMr@_$c}d+;_B`#kUX-t|4VKH&_f^^EP0&=DPLW)H)UzBG%%Tra*5 z%$kyZe3I&S#gfie^z5)!twG={3Cuh)FdeA!Kj<-9** zvT*5%Tb`|QbE!iW-XcOuy39>D3oe6x{>&<#E$o8Ac|j)wq#kQzz|ATd=Z0K!p2$QE zPu?jL8Lb^y3_CQE{*}sTDe!2!dtlFjq&YLY@2#4>XS`}v#PLrpvc4*@q^O{mmnr5D zmyJq~t?8>FWU5vZdE(%4cuZuao0GNjp3~Dt*SLaxI#g_u>hu@k&9Ho*#CZP~lFJHj z(e!SYlLigyc?&5-YxlE{uuk$9b&l6d`uIlpg_z15dPo*iU&|Khx2*A5Fp;8iK_bdP z?T6|^7@lcx2j0T@x>X7|kuuBSB7<^zeY~R~4McconTxA2flHC0_jFxmSTv-~?zVT| zG_|yDqa9lkF*B6_{j=T>=M8r<0s;@z#h)3BQ4NLl@`Xr__o7;~M&dL3J8fP&zLfDfy z);ckcTev{@OUlZ`bCo(-3? z1u1xD`PKgSg?RqeVVsF<1SLF;XYA@Bsa&cY!I48ZJn1V<3d!?s=St?TLo zC0cNr`qD*M#s6f~X>SCNVkva^9A2ZP>CoJ9bvgXe_c}WdX-)pHM5m7O zrHt#g$F0AO+nGA;7dSJ?)|Mo~cf{z2L)Rz!`fpi73Zv)H=a5K)*$5sf_IZypi($P5 zsPwUc4~P-J1@^3C6-r9{V-u0Z&Sl7vNfmuMY4yy*cL>_)BmQF!8Om9Dej%cHxbIzA zhtV0d{=%cr?;bpBPjt@4w=#<>k5ee=TiWAXM2~tUGfm z$s&!Dm0R^V$}fOR*B^kGaipi~rx~A2cS0;t&khV1a4u38*XRUP~f za!rZMtay8bsLt6yFYl@>-y^31(*P!L^^s@mslZy(SMsv9bVoX`O#yBgEcjCmGpyc* zeH$Dw6vB5P*;jor+JOX@;6K#+xc)Z9B8M=x2a@Wx-{snPGpRmOC$zpsqW*JCh@M2Y z#K+M(>=#d^>Of9C`))h<=Bsy)6zaMJ&x-t%&+UcpLjV`jo4R2025 zXaG8EA!0lQa)|dx-@{O)qP6`$rhCkoQqZ`^SW8g-kOwrwsK8 z3ms*AIcyj}-1x&A&vSq{r=QMyp3CHdWH35!sad#!Sm>^|-|afB+Q;|Iq@LFgqIp#Z zD1%H+3I?6RGnk&IFo|u+E0dCxXz4yI^1i!QTu7uvIEH>i3rR{srcST`LIRwdV1P;W z+%AN1NIf@xxvVLiSX`8ILA8MzNqE&7>%jMzGt9wm78bo9<;h*W84i29^w!>V>{N+S zd`5Zmz^G;f=icvoOZfK5#1ctx*~UwD=ab4DGQXehQ!XYnak*dee%YN$_ZPL%KZuz$ zD;$PpT;HM^$KwtQm@7uvT`i6>Hae1CoRVM2)NL<2-k2PiX=eAx+-6j#JI?M}(tuBW zkF%jjLR)O`gI2fcPBxF^HeI|DWwQWHVR!;;{BXXHskxh8F@BMDn`oEi-NHt;CLymW z=KSv5)3dyzec0T5B*`g-MQ<;gz=nIWKUi9ko<|4I(-E0k$QncH>E4l z**1w&#={&zv4Tvhgz#c29`m|;lU-jmaXFMC11 z*dlXDMEOG>VoLMc>!rApwOu2prKSi*!w%`yzGmS+k(zm*CsLK*wv{S_0WX^8A-rKy zbk^Gf_92^7iB_uUF)EE+ET4d|X|>d&mdN?x@vxKAQk`O+r4Qdu>XGy(a(19g;=jU} zFX{O*_NG>!$@jh!U369Lnc+D~qch3uT+_Amyi}*k#LAAwh}k8IPK5a-WZ81ufD>l> z$4cF}GSz>ce`3FAic}6W4Z7m9KGO?(eWqi@L|5Hq0@L|&2flN1PVl}XgQ2q*_n2s3 zt5KtowNkTYB5b;SVuoXA@i5irXO)A&%7?V`1@HGCB&)Wgk+l|^XXChq;u(nyPB}b3 zY>m5jkxpZgi)zfbgv&ec4Zqdvm+D<?Im*mXweS9H+V>)zF#Zp3)bhl$PbISY{5=_z!8&*Jv~NYtI-g!>fDs zmvL5O^U%!^VaKA9gvKw|5?-jk>~%CVGvctKmP$kpnpfN{D8@X*Aazi$txfa%vd-|E z>kYmV66W!lNekJPom29LdZ%(I+ZLZYTXzTg*to~m?7vp%{V<~>H+2}PQ?PPAq`36R z<%wR8v6UkS>Wt#hzGk#44W<%9S=nBfB);6clKwnxY}T*w21Qc3_?IJ@4gYzC7s;WP zVQNI(M=S=JT#xsZy7G`cR(BP9*je0bfeN8JN5~zY(DDs0t{LpHOIbN);?T-69Pf3R zSNe*&p2%AwXHL>__g+xd4Hlc_vu<25H?(`nafS%)3UPP7_4;gk-9ckt8SJRTv5v0M z_Hww`qPudL?ajIR&X*;$y-`<)6dxx1U~5eGS13CB!lX;3w7n&lDDiArbAhSycd}+b zya_3p@A`$kQy;|NJZ~s44Hqo7Hwt}X86NK=(ey>lgWTtGL6k@Gy;PbO!M%1~Wcn2k zUFP|*5d>t-X*RU8g%>|(wwj*~#l4z^Aatf^DWd1Wj#Q*AY0D^V@sC`M zjJc6qXu0I7Y*2;;gGu!plAFzG=J;1%eIOdn zQA>J&e05UN*7I5@yRhK|lbBSfJ+5Uq;!&HV@xfPZrgD}kE*1DSq^=%{o%|LChhl#0 zlMb<^a6ixzpd{kNZr|3jTGeEzuo}-eLT-)Q$#b{!vKx8Tg}swCni>{#%vDY$Ww$84 zew3c9BBovqb}_&BRo#^!G(1Eg((BScRZ}C)Oz?y`T5wOrv);)b^4XR8 zhJo7+<^7)qB>I;46!GySzdneZ>n_E1oWZY;kf94#)s)kWjuJN1c+wbVoNQcmnv}{> zN0pF+Sl3E}UQ$}slSZeLJrwT>Sr}#V(dVaezCQl2|4LN`7L7v&siYR|r7M(*JYfR$ zst3=YaDw$FSc{g}KHO&QiKxuhEzF{f%RJLKe3p*7=oo`WNP)M(9X1zIQPP0XHhY3c znrP{$4#Ol$A0s|4S7Gx2L23dv*Gv2o;h((XVn+9+$qvm}s%zi6nI-_s6?mG! zj{DV;qesJb&owKeEK?=J>UcAlYckA7Sl+I&IN=yasrZOkejir*kE@SN`fk<8Fgx*$ zy&fE6?}G)d_N`){P~U@1jRVA|2*69)KSe_}!~?+`Yb{Y=O~_+@!j<&oVQQMnhoIRU zA0CyF1OFfkK44n*JD~!2!SCPM;PRSk%1XL=0&rz00wxPs&-_eapJy#$h!eqY%nS0{ z!aGg58JIJPF3_ci%n)QSVpa2H`vIe$RD43;#IRfDV&Ibit z+?>HW4{2wOfC6Fw)}4x}i1maDxcE1qi@BS*qcxD2gE@h3#4cgU*D-&3z7D|tVZWt= z-Cy2+*Cm@P4GN_TPUtaVyVesbVDazF@)j8VJ4>XZv!f%}&eO1SvIgr}4`A*3#vat< z_MoByL(qW6L7SFZ#|Gc1fFN)L2PxY+{B8tJp+pxRyz*87)vXR}*=&ahXjBlQKguuf zX6x<<6fQulE^C*KH8~W%ptpaC0l?b=_{~*U4?5Vt;dgM4t_{&UZ1C2j?b>b+5}{IF_CUyvz-@QZPMlJ)r_tS$9kH%RPv#2_nMb zRLj5;chJ72*U`Z@Dqt4$@_+k$%|8m(HqLG!qT4P^DdfvGf&){gKnGCX#H0!;W=AGP zbA&Z`-__a)VTS}kKFjWGk z%|>yE?t*EJ!qeQ%dPk$;xIQ+P0;()PCBDgjJm6Buj{f^awNoVx+9<|lg3%-$G(*f) zll6oOkN|yamn1uyl2*N-lnqRI1cvs_JxLTeahEK=THV$Sz*gQhKNb*p0fNoda#-&F zB-qJgW^g}!TtM|0bS2QZekW7_tKu%GcJ!4?lObt0z_$mZ4rbQ0o=^curCs3bJK6sq z9fu-aW-l#>z~ca(B;4yv;2RZ?tGYAU)^)Kz{L|4oPj zdOf_?de|#yS)p2v8-N||+XL=O*%3+y)oI(HbM)Ds?q8~HPzIP(vs*G`iddbWq}! z(2!VjP&{Z1w+%eUq^ '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts new file mode 100644 index 0000000..6a9124a --- /dev/null +++ b/lib/build.gradle.kts @@ -0,0 +1,86 @@ +import com.vanniktech.maven.publish.SonatypeHost + +object Metadata { + const val DESC = "Java library for retrieving YouTube transcripts. " + + "It supports manual and automatically generated subtitles and does not use headless browser for scraping" + const val GROUP_ID = "io.github.thoroldvix" + const val LICENSE = "MIT" + const val LICENSE_URL = "https://opensource.org/licenses/MIT" + const val GITHUB_REPO = "thoroldvix/youtube-transcript-api" + const val DEVELOPER_ID = "thoroldvix" + const val DEVELOPER_NAME = "Alexey Bobkov" + const val DEVELOPER_EMAIL = "dignitionn@gmail.com" +} + +plugins { + `java-library` + alias(libs.plugins.maven.publish) + alias(libs.plugins.gradle.release) +} + +repositories { + mavenCentral() +} + +tasks.withType { + val javaVersion = if (name == "compileTestJava") 21 else 11 + javaCompiler = javaToolchains.compilerFor { languageVersion = JavaLanguageVersion.of(javaVersion) } +} + +tasks.getByName("test") { + useJUnitPlatform() +} + +dependencies { + implementation(libs.jackson.dataformat.xml) + implementation(libs.apache.commons.text) + + testRuntimeOnly(libs.junit.jupiter.platform.launcher) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertj.core) + testImplementation(libs.mockito.junit.jupiter) +} + +release { + versionPropertyFile = "../gradle.properties" + newVersionCommitMessage = "chore: set next development version to" + preTagCommitMessage = "chore: bump current version to" +} + +mavenPublishing { + coordinates(groupId = Metadata.GROUP_ID, artifactId = rootProject.name, version = project.version.toString()) + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + + pom { + name = project.name + description = Metadata.DESC + url = "https://github.com/${Metadata.GITHUB_REPO}" + inceptionYear = "2024" + + licenses { + license { + name = Metadata.LICENSE + url = Metadata.LICENSE_URL + } + } + + developers { + developer { + id = Metadata.DEVELOPER_ID + name = Metadata.DEVELOPER_NAME + email = Metadata.DEVELOPER_EMAIL + } + } + + scm { + connection = "scm:git:git://github.com/${Metadata.GITHUB_REPO}.git" + developerConnection = "scm:git:ssh://github.com:${Metadata.GITHUB_REPO}.git" + url = "https://github.com/${Metadata.GITHUB_REPO}.git" + } + + issueManagement { + url = "https://github.com/${Metadata.GITHUB_REPO}/issues" + } + } + signAllPublications() +} \ No newline at end of file diff --git a/lib/src/main/java/io/github/thoroldvix/api/Transcript.java b/lib/src/main/java/io/github/thoroldvix/api/Transcript.java new file mode 100644 index 0000000..3f44a31 --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/api/Transcript.java @@ -0,0 +1,79 @@ +package io.github.thoroldvix.api; + +import java.util.Set; + +/** + * Represents a single transcript for a YouTube video, including its metadata. + *

+ * This interface provides methods to access the transcript content and to perform translations into different languages. + * Individual transcripts can be obtained through the {@link TranscriptList} class. + *

+ */ +public interface Transcript { + + /** + * Retrieves the content of the transcript. + * + * @return The content of the transcript as a {@link TranscriptContent} object. + * @throws TranscriptRetrievalException If the transcript content cannot be retrieved. + */ + TranscriptContent fetch() throws TranscriptRetrievalException; + + /** + * Gets the video id of the transcript. + * + * @return The video id as a {@link String}. + */ + String getVideoId(); + + /** + * Gets the language of the transcript. + * + * @return The language as a {@link String}. + */ + String getLanguage(); + + /** + * Gets the language code of the transcript. + * + * @return The language code as a {@link String}. + */ + String getLanguageCode(); + + /** + * Returns API URL which needs to be called to fetch transcript content. + * + * @return {@link String} API URL to fetch transcript content + */ + String getApiUrl(); + + /** + * Determines if the transcript was automatically generated by YouTube. + * + * @return {@code true} if the transcript was automatically generated; {@code false} otherwise. + */ + boolean isGenerated(); + + /** + * Lists all available translation languages for the transcript. + * + * @return A set of language codes representing available translation languages. + */ + Set getTranslationLanguages(); + + /** + * Indicates whether the transcript can be translated. + * + * @return {@code true} if the transcript is translatable; {@code false} otherwise. + */ + boolean isTranslatable(); + + /** + * Translates the transcript into the specified language. + * + * @param languageCode The language code to which the transcript should be translated. + * @return A {@link Transcript} representing the translated transcript. + * @throws TranscriptRetrievalException If the transcript cannot be translated. + */ + Transcript translate(String languageCode) throws TranscriptRetrievalException; +} diff --git a/lib/src/main/java/io/github/thoroldvix/api/TranscriptContent.java b/lib/src/main/java/io/github/thoroldvix/api/TranscriptContent.java new file mode 100644 index 0000000..ee017e0 --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/api/TranscriptContent.java @@ -0,0 +1,62 @@ +package io.github.thoroldvix.api; + +import java.util.List; + +/** + * Represents the content of a transcript for a single video. + *

+ * When the transcript content is fetched from YouTube, it is provided in the form of XML containing multiple transcript fragments. + *

+ * For example: + *

+ *
{@code
+ *    
+ *         Text
+ *         0.0
+ *         1.54
+ *    
+ *    
+ *         Another text
+ *         1.54
+ *         4.16
+ *    
+ * }
+ * This interface encapsulates the transcript content as a {@code List}. + */ +public interface TranscriptContent { + + /** + * Retrieves a list of {@link Fragment} objects that represent the content of the transcript. + * + * @return A {@link List} of {@link Fragment} objects. + */ + List getContent(); + + /** + * Represents a single fragment of the transcript content. + */ + interface Fragment { + /** + * Retrieves the text of the fragment. + * + * @return The text of the fragment as a {@link String}. + */ + String getText(); + + /** + * Retrieves the start time of the fragment in seconds. + * + * @return The start time of the fragment as a {@link Double}. + */ + double getStart(); + + /** + * Retrieves the duration of the fragment in seconds. + * + * @return The duration of the fragment as a {@link Double}. + */ + double getDur(); + } +} + + diff --git a/lib/src/main/java/io/github/thoroldvix/api/TranscriptFormatter.java b/lib/src/main/java/io/github/thoroldvix/api/TranscriptFormatter.java new file mode 100644 index 0000000..a136779 --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/api/TranscriptFormatter.java @@ -0,0 +1,16 @@ +package io.github.thoroldvix.api; + +/** + * Represents a formatter for transcript content. + */ +@FunctionalInterface +public interface TranscriptFormatter { + + /** + * Formats the transcript content. + * + * @param transcriptContent The {@link TranscriptContent} to format. + * @return The formatted transcript content as a {@link String}. + */ + String format(TranscriptContent transcriptContent); +} diff --git a/lib/src/main/java/io/github/thoroldvix/api/TranscriptFormatters.java b/lib/src/main/java/io/github/thoroldvix/api/TranscriptFormatters.java new file mode 100644 index 0000000..d2680b2 --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/api/TranscriptFormatters.java @@ -0,0 +1,121 @@ +package io.github.thoroldvix.api; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import io.github.thoroldvix.api.TranscriptContent.Fragment; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Responsible for creating {@link TranscriptFormatter} instances. + *

+ * Available formatters are: + *

+ *
    + *
  • {@link #jsonFormatter()}
  • + *
  • {@link #prettyJsonFormatter()}
  • + *
  • {@link #textFormatter()}
  • + *
  • {@link #webVTTFormatter()}
  • + *
  • {@link #srtFormatter()}
  • + *
+ */ +public final class TranscriptFormatters { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private TranscriptFormatters() { + } + + /** + * Creates a {@link TranscriptFormatter} that formats transcript content as JSON. + * + * @return A {@link TranscriptFormatter} for JSON format. + */ + public static TranscriptFormatter jsonFormatter() { + return transcriptContent -> formatAsJSON(transcriptContent, OBJECT_MAPPER.writer()); + } + + /** + * Creates a {@link TranscriptFormatter} that formats transcript content as pretty-printed JSON. + * + * @return A {@link TranscriptFormatter} for pretty-printed JSON format. + */ + public static TranscriptFormatter prettyJsonFormatter() { + return transcriptContent -> formatAsJSON(transcriptContent, OBJECT_MAPPER.writerWithDefaultPrettyPrinter()); + } + + private static String formatAsJSON(TranscriptContent transcriptContent, ObjectWriter objectWriter) { + try { + return objectWriter.writeValueAsString(transcriptContent); + } catch (JsonProcessingException e) { + return "{}"; + } + } + + /** + * Creates a {@link TranscriptFormatter} that formats transcript content as plain text without timestamps. + * + * @return A {@link TranscriptFormatter} for plain text format. + */ + public static TranscriptFormatter textFormatter() { + return transcriptContent -> transcriptContent.getContent().stream() + .map(Fragment::getText) + .collect(Collectors.joining("\n")); + } + + /** + * Creates a {@link TranscriptFormatter} that formats transcript content as WebVTT format. + *

+ * See WebVTT specification for more information. + *

+ * + * @return A {@link TranscriptFormatter} for WebVTT format. + */ + public static TranscriptFormatter webVTTFormatter() { + return transcriptContent -> "WEBVTT\n\n" + formatAsSubtitles( + transcriptContent, + fragment -> String.format("%s%n%s", fragmentToTimeStamp(fragment), fragment.getText()) + ); + } + + private static String formatAsSubtitles(TranscriptContent transcriptContent, Function formatter) { + return transcriptContent.getContent().stream() + .map(formatter) + .collect(Collectors.joining("\n\n")); + } + + private static String fragmentToTimeStamp(Fragment fragment) { + return String.format("%s --> %s", + secondsToTimeStamp(fragment.getStart()), + secondsToTimeStamp(fragment.getStart() + fragment.getDur())); + } + + private static String secondsToTimeStamp(double seconds) { + int hour = (int) (seconds / 3600); + int minute = (int) ((seconds % 3600) / 60); + int second = (int) (seconds % 60); + int millisecond = (int) ((seconds % 1) * 1000); + return String.format("%02d:%02d:%02d.%03d", hour, minute, second, millisecond); + } + + /** + * Creates a {@link TranscriptFormatter} that formats transcript content as SRT (SubRip) subtitles. + *

+ * See SRT file format for more information. + *

+ * + * @return A {@link TranscriptFormatter} for SRT format. + */ + public static TranscriptFormatter srtFormatter() { + return transcriptContent -> { + AtomicInteger i = new AtomicInteger(1); + return formatAsSubtitles( + transcriptContent, + fragment -> String.format("%d%n%s%n%s", i.getAndIncrement(), fragmentToTimeStamp(fragment), fragment.getText()) + ); + }; + } +} diff --git a/lib/src/main/java/io/github/thoroldvix/api/TranscriptList.java b/lib/src/main/java/io/github/thoroldvix/api/TranscriptList.java new file mode 100644 index 0000000..e14205d --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/api/TranscriptList.java @@ -0,0 +1,63 @@ +package io.github.thoroldvix.api; + +import java.util.function.Consumer; + +/** + * Represents a list of all available transcripts for a YouTube video. + *

+ * This interface provides methods to iterate over all available transcripts for a given YouTube video, and to find either generated or manual transcripts for a specific language. + * Individual transcripts are represented by {@link Transcript} objects. + * Instances of {@link TranscriptList} can be obtained through the {@link YoutubeTranscriptApi} class. + *

+ */ +public interface TranscriptList extends Iterable { + + /** + * Searches for a transcript using the provided language codes. + * Manually created transcripts are prioritized, and if none are found, generated transcripts are used. + * If you only want generated or manually created transcripts, use {@link #findGeneratedTranscript(String...)} or {@link #findManualTranscript(String...)} instead. + * + * @param languageCodes A varargs list of language codes in descending priority. + *

+ * For example: + *

+ * If this is set to {@code ("de", "en")}, it will first attempt to fetch the German transcript ("de"), and then fetch the English + * transcript ("en") if the former fails. If no language code is provided, it uses English as the default language. + * @return The found {@link Transcript}. + * @throws TranscriptRetrievalException If no transcript could be found for the given language codes. + */ + Transcript findTranscript(String... languageCodes) throws TranscriptRetrievalException; + + /** + * Searches for an automatically generated transcript using the provided language codes. + * + * @param languageCodes A varargs list of language codes in descending priority. + *

+ * For example: + *

+ * If this is set to {@code ("de", "en")}, it will first attempt to fetch the German transcript ("de"), and then fetch the English + * transcript ("en") if the former fails. If no language code is provided, it uses English as the default language. + * @return The found {@link Transcript}. + * @throws TranscriptRetrievalException If no transcript could be found for the given language codes. + */ + Transcript findGeneratedTranscript(String... languageCodes) throws TranscriptRetrievalException; + + /** + * Searches for a manually created transcript using the provided language codes. + * + * @param languageCodes A varargs list of language codes in descending priority. + *

+ * For example: + *

+ * If this is set to {@code ("de", "en")}, it will first attempt to fetch the German transcript ("de"), and then fetch the English + * transcript ("en") if the former fails. If no language code is provided, it uses English as the default language. + * @return The found {@link Transcript}. + * @throws TranscriptRetrievalException If no transcript could be found for the given language codes. + */ + Transcript findManualTranscript(String... languageCodes) throws TranscriptRetrievalException; + + @Override + default void forEach(Consumer action) { + Iterable.super.forEach(action); + } +} diff --git a/lib/src/main/java/io/github/thoroldvix/api/TranscriptRetrievalException.java b/lib/src/main/java/io/github/thoroldvix/api/TranscriptRetrievalException.java new file mode 100644 index 0000000..02e62d4 --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/api/TranscriptRetrievalException.java @@ -0,0 +1,47 @@ +package io.github.thoroldvix.api; + +/** + * Exception thrown when a transcript cannot be retrieved for a specified video. + *

+ * This exception encapsulates the details of the error encountered during the retrieval of a YouTube video transcript. + *

+ */ +public class TranscriptRetrievalException extends Exception { + + private static final String ERROR_MESSAGE = "Could not retrieve transcript for the video: %s.\nReason: %s"; + private static final String YOUTUBE_WATCH_URL = "https://www.youtube.com/watch?v="; + + /** + * Constructs a new exception with the specified detail message and cause. + * + * @param videoId The ID of the video for which the transcript retrieval failed. + * @param message The detail message explaining the reason for the failure. + * @param cause The cause of the failure (which is saved for later retrieval by the {@link Throwable#getCause()} method). + */ + public TranscriptRetrievalException(String videoId, String message, Throwable cause) { + super(buildErrorMessage(videoId, message), cause); + } + + /** + * Constructs a new exception with the specified detail message. + * + * @param videoId The ID of the video for which the transcript retrieval failed. + * @param message The detail message explaining the reason for the failure. + */ + public TranscriptRetrievalException(String videoId, String message) { + super(buildErrorMessage(videoId, message)); + } + + /** + * Builds the error message to include the video URL and the specific cause of the error. + * + * @param videoId The ID of the video for which the transcript retrieval failed. + * @param message The detail message explaining the reason for the failure. + * @return The formatted error message. + */ + private static String buildErrorMessage(String videoId, String message) { + String videoUrl = YOUTUBE_WATCH_URL + videoId; + return String.format(ERROR_MESSAGE, videoUrl, message); + } +} + diff --git a/lib/src/main/java/io/github/thoroldvix/api/YoutubeClient.java b/lib/src/main/java/io/github/thoroldvix/api/YoutubeClient.java new file mode 100644 index 0000000..23893a7 --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/api/YoutubeClient.java @@ -0,0 +1,22 @@ +package io.github.thoroldvix.api; + + +import java.util.Map; + +/** + * Responsible for sending GET requests to YouTube. + */ +@FunctionalInterface +public interface YoutubeClient { + + /** + * Sends a GET request to the specified URL and returns the response body. + * + * @param url The URL to which the GET request is made. + * @param headers A map of additional headers to include in the request. + * @return The body of the response as a {@link String}. + * @throws TranscriptRetrievalException If the request to YouTube fails. + */ + String get(String url, Map headers) throws TranscriptRetrievalException; +} + diff --git a/lib/src/main/java/io/github/thoroldvix/api/YoutubeTranscriptApi.java b/lib/src/main/java/io/github/thoroldvix/api/YoutubeTranscriptApi.java new file mode 100644 index 0000000..4b1922b --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/api/YoutubeTranscriptApi.java @@ -0,0 +1,97 @@ +package io.github.thoroldvix.api; + +import io.github.thoroldvix.internal.TranscriptApiFactory; + +/** + * This is the main interface for the YouTube Transcript API. + *

+ * It provides functionality for retrieving all available transcripts or retrieving actual transcript content from YouTube. + *

+ *

+ * To instantiate this API, you should use {@link TranscriptApiFactory}. + *

+ */ +public interface YoutubeTranscriptApi { + + /** + * Retrieves a list of available transcripts for a given video using cookies from a specified file path. + *

+ * Used when you want to list transcripts for a video that is age-restricted. + * It tries to bypass the age-restriction by using the provided authentication cookies. + *

+ *

+ * Note: For more information on how to obtain the authentication cookies, + * see the GitHub page. + *

+ * + * @param videoId The ID of the video + * @param cookiesPath The file path to the text file containing the authentication cookies + * @return {@link TranscriptList} A list of all available transcripts for the given video + * @throws TranscriptRetrievalException If the retrieval of the transcript list fails + * @throws IllegalArgumentException If the video ID is invalid + */ + TranscriptList listTranscriptsWithCookies(String videoId, String cookiesPath) throws TranscriptRetrievalException; + + /** + * Retrieves a list of available transcripts for a given video. + * + * @param videoId The ID of the video + * @return {@link TranscriptList} A list of all available transcripts for the given video + * @throws TranscriptRetrievalException If the retrieval of the transcript list fails + * @throws IllegalArgumentException If the video ID is invalid + */ + TranscriptList listTranscripts(String videoId) throws TranscriptRetrievalException; + + /** + * Retrieves transcript content for a given video using cookies from a specified file path. + *

+ * Used when you want to retrieve transcript content for a video that is age-restricted. + * It tries to bypass the age-restriction by using the provided authentication cookies. + *

+ *

+ * Note: For more information on how to obtain the authentication cookies, + * see the GitHub page. + *

+ *

+ * This is a shortcut for calling: + *

+ *

+ * {@code listTranscriptsWithCookies(videoId).findTranscript(languageCodes).fetch();} + *

+ * + * @param videoId The ID of the video + * @param languageCodes A varargs list of language codes in descending priority. + *

+ * For example: + *

+ * If this is set to {@code ("de", "en")}, it will first attempt to fetch the German transcript ("de"), and then fetch the English + * transcript ("en") if the former fails. If no language code is provided, it uses English as the default language. + * @param cookiesPath The file path to the text file containing the authentication cookies + * @return {@link TranscriptContent} The transcript content + * @throws TranscriptRetrievalException If the retrieval of the transcript fails + * @throws IllegalArgumentException If the video ID is invalid + */ + TranscriptContent getTranscriptWithCookies(String videoId, String cookiesPath, String... languageCodes) throws TranscriptRetrievalException; + + /** + * Retrieves transcript content for a single video. + *

+ * This is a shortcut for calling: + *

+ *

+ * {@code listTranscripts(videoId).findTranscript(languageCodes).fetch();} + *

+ * + * @param videoId The ID of the video + * @param languageCodes A varargs list of language codes in descending priority. + *

+ * For example: + *

+ * If this is set to {@code ("de", "en")}, it will first attempt to fetch the German transcript ("de"), and then fetch the English + * transcript ("en") if the former fails. If no language code is provided, it uses English as the default language. + * @return {@link TranscriptContent} The transcript content + * @throws TranscriptRetrievalException If the retrieval of the transcript fails + * @throws IllegalArgumentException If the video ID is invalid + */ + TranscriptContent getTranscript(String videoId, String... languageCodes) throws TranscriptRetrievalException; +} diff --git a/lib/src/main/java/io/github/thoroldvix/internal/DefaultTranscript.java b/lib/src/main/java/io/github/thoroldvix/internal/DefaultTranscript.java new file mode 100644 index 0000000..31f2f7d --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/internal/DefaultTranscript.java @@ -0,0 +1,144 @@ +package io.github.thoroldvix.internal; + + +import io.github.thoroldvix.api.Transcript; +import io.github.thoroldvix.api.TranscriptContent; +import io.github.thoroldvix.api.TranscriptRetrievalException; +import io.github.thoroldvix.api.YoutubeClient; + +import java.util.*; + +/** + * Default implementation of {@link Transcript}. + */ +final class DefaultTranscript implements Transcript { + + private final YoutubeClient client; + private final String videoId; + private final String apiUrl; + private final String language; + private final String languageCode; + private final boolean isGenerated; + private final Map translationLanguages; + private final boolean isTranslatable; + + DefaultTranscript(YoutubeClient client, + String videoId, + String apiUrl, + String language, + String languageCode, + boolean isGenerated, + Map translationLanguages) { + this.client = client; + this.videoId = videoId; + this.apiUrl = apiUrl; + this.language = language; + this.languageCode = languageCode; + this.isGenerated = isGenerated; + this.translationLanguages = translationLanguages; + this.isTranslatable = translationLanguages != null && !translationLanguages.isEmpty(); + } + + @Override + public TranscriptContent fetch() throws TranscriptRetrievalException { + String transcriptXml = client.get(apiUrl, Map.of("Accept-Language", "en-US")); + TranscriptContentXML transcriptXML = new TranscriptContentXML(transcriptXml, videoId); + + return transcriptXML.transcriptContent(); + } + + @Override + public Transcript translate(String languageCode) throws TranscriptRetrievalException { + checkIfPossibleToTranslate(languageCode); + return new DefaultTranscript( + client, + videoId, + createTranslationApiUrl(languageCode), + translationLanguages.get(languageCode), + languageCode, + isGenerated, + translationLanguages + ); + } + + private void checkIfPossibleToTranslate(String languageCode) throws TranscriptRetrievalException { + if (!isTranslatable) { + throw new TranscriptRetrievalException(videoId, "This transcript is not translatable"); + } + if (!translationLanguages.containsKey(languageCode)) { + throw new TranscriptRetrievalException(videoId, String.format("Translation language '%s' is not available", languageCode)); + } + } + + private String createTranslationApiUrl(String languageCode) { + return String.format("%s&tlang=%s", apiUrl, languageCode); + } + + @Override + public String getVideoId() { + return videoId; + } + + @Override + public String getLanguage() { + return language; + } + + @Override + public String getApiUrl() { + return apiUrl; + } + + @Override + public String getLanguageCode() { + return languageCode; + } + + @Override + public boolean isGenerated() { + return isGenerated; + } + + @Override + public Set getTranslationLanguages() { + return Collections.unmodifiableSet(translationLanguages.keySet()); + } + + @Override + public boolean isTranslatable() { + return isTranslatable; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DefaultTranscript that = (DefaultTranscript) o; + return isGenerated == that.isGenerated && isTranslatable == that.isTranslatable && + Objects.equals(client, that.client) && Objects.equals(videoId, that.videoId) && + Objects.equals(apiUrl, that.apiUrl) && Objects.equals(language, that.language) && + Objects.equals(languageCode, that.languageCode) && + Objects.equals(translationLanguages, that.translationLanguages); + } + + @Override + public int hashCode() { + return Objects.hash(client, videoId, apiUrl, language, languageCode, isGenerated, translationLanguages, isTranslatable); + } + + @Override + public String toString() { + String template = "Transcript for video with id: %s.\n" + + "Language: %s\n" + + "Language code: %s\n" + + "API URL for retrieving content: %s\n" + + "Available translation languages: %s"; + + return String.format(template, + videoId, + language, + languageCode, + apiUrl, + new TreeSet<>(translationLanguages.keySet())); + } +} diff --git a/lib/src/main/java/io/github/thoroldvix/internal/DefaultTranscriptContent.java b/lib/src/main/java/io/github/thoroldvix/internal/DefaultTranscriptContent.java new file mode 100644 index 0000000..b5715f6 --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/internal/DefaultTranscriptContent.java @@ -0,0 +1,112 @@ +package io.github.thoroldvix.internal; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlText; +import io.github.thoroldvix.api.TranscriptContent; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Default implementation of {@link TranscriptContent} + */ +final class DefaultTranscriptContent implements TranscriptContent { + + private final List content; + + public DefaultTranscriptContent(List content) { + this.content = content; + } + + @Override + public List getContent() { + return Collections.unmodifiableList(content); + } + + @Override + public int hashCode() { + return Objects.hashCode(content); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DefaultTranscriptContent that = (DefaultTranscriptContent) o; + return Objects.equals(content, that.content); + } + + @Override + public String toString() { + return "content=[" + content.stream() + .map(Object::toString) + .collect(Collectors.joining(", ")) + "]"; + } + + /** + * Default implementation of {@link TranscriptContent.Fragment} + */ + @JacksonXmlRootElement(localName = "transcript") + static final class Fragment implements TranscriptContent.Fragment { + @JacksonXmlText + @JsonProperty("text") + private String text; + @JacksonXmlProperty(isAttribute = true) + @JsonProperty("start") + private double start; + @JacksonXmlProperty(isAttribute = true) + @JsonProperty("dur") + private double dur; + + public Fragment(String text, double start, double dur) { + this.text = text; + this.start = start; + this.dur = dur; + } + + Fragment() { + } + + @Override + public String getText() { + return text; + } + + @Override + public double getStart() { + return start; + } + + @Override + public double getDur() { + return dur; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Fragment fragment = (Fragment) o; + return Double.compare(start, fragment.start) == 0 && Double.compare(dur, fragment.dur) == 0 && Objects.equals(text, fragment.text); + } + + @Override + public int hashCode() { + return Objects.hash(text, start, dur); + } + + @Override + public String toString() { + return "{" + + "text='" + text + '\'' + + ", start=" + start + + ", dur=" + dur + + '}'; + } + } +} + diff --git a/lib/src/main/java/io/github/thoroldvix/internal/DefaultTranscriptList.java b/lib/src/main/java/io/github/thoroldvix/internal/DefaultTranscriptList.java new file mode 100644 index 0000000..e5649bd --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/internal/DefaultTranscriptList.java @@ -0,0 +1,123 @@ +package io.github.thoroldvix.internal; + +import io.github.thoroldvix.api.Transcript; +import io.github.thoroldvix.api.TranscriptList; +import io.github.thoroldvix.api.TranscriptRetrievalException; + +import java.util.*; + +/** + * Default implementation of {@link TranscriptList} + */ +final class DefaultTranscriptList implements TranscriptList { + + private final String videoId; + private final Map manualTranscripts; + private final Map generatedTranscripts; + private final Map translationLanguages; + + DefaultTranscriptList(String videoId, + Map manualTranscripts, + Map generatedTranscripts, + Map translationLanguages) { + this.videoId = videoId; + this.manualTranscripts = manualTranscripts; + this.generatedTranscripts = generatedTranscripts; + this.translationLanguages = translationLanguages; + } + + @Override + public Transcript findTranscript(String... languageCodes) throws TranscriptRetrievalException { + try { + return findManualTranscript(languageCodes); + } catch (TranscriptRetrievalException e) { + return findGeneratedTranscript(languageCodes); + } + } + + @Override + public Transcript findManualTranscript(String... languageCodes) throws TranscriptRetrievalException { + return findTranscript(manualTranscripts, getDefault(languageCodes)); + } + + private Transcript findTranscript(Map transcripts, String... languageCodes) throws TranscriptRetrievalException { + validateLanguageCodes(languageCodes); + for (String languageCode : languageCodes) { + if (transcripts.containsKey(languageCode)) { + return transcripts.get(languageCode); + } + } + throw new TranscriptRetrievalException(videoId, String.format("No transcripts were found for any of the requested language codes: %s. %s.", Arrays.toString(languageCodes), this)); + } + + private static String[] getDefault(String[] languageCodes) { + return languageCodes.length == 0 ? new String[]{"en"} : languageCodes; + } + + @Override + public Transcript findGeneratedTranscript(String... languageCodes) throws TranscriptRetrievalException { + return findTranscript(generatedTranscripts, getDefault(languageCodes)); + } + + @Override + public Iterator iterator() { + return new Iterator<>() { + private final Iterator manualIterator = manualTranscripts.values().iterator(); + private final Iterator generatedIterator = generatedTranscripts.values().iterator(); + + @Override + public boolean hasNext() { + return manualIterator.hasNext() || generatedIterator.hasNext(); + } + + @Override + public Transcript next() { + if (manualIterator.hasNext()) { + return manualIterator.next(); + } + return generatedIterator.next(); + } + }; + } + + private static void validateLanguageCodes(String... languageCodes) { + for (String languageCode : languageCodes) { + if (languageCode == null) { + throw new IllegalArgumentException("Language codes cannot be null"); + } + if (languageCode.isBlank()) { + throw new IllegalArgumentException("Language codes cannot be blank"); + } + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DefaultTranscriptList that = (DefaultTranscriptList) o; + return Objects.equals(videoId, that.videoId) && Objects.equals(manualTranscripts, that.manualTranscripts) && Objects.equals(generatedTranscripts, that.generatedTranscripts); + } + + @Override + public int hashCode() { + return Objects.hash(videoId, manualTranscripts, generatedTranscripts); + } + + @Override + public String toString() { + String template = "For video with ID (%s) transcripts are available in the following languages:\n" + + "Manually created: " + + "%s\n" + + "Automatically generated: " + + "%s\n" + + "Available translation languages: " + + "%s"; + + return String.format(template, + videoId, + new TreeSet<>(manualTranscripts.keySet()), + new TreeSet<>(generatedTranscripts.keySet()), + new TreeSet<>(translationLanguages.keySet())); + } +} diff --git a/lib/src/main/java/io/github/thoroldvix/internal/DefaultYoutubeClient.java b/lib/src/main/java/io/github/thoroldvix/internal/DefaultYoutubeClient.java new file mode 100644 index 0000000..711362b --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/internal/DefaultYoutubeClient.java @@ -0,0 +1,69 @@ +package io.github.thoroldvix.internal; + +import io.github.thoroldvix.api.TranscriptRetrievalException; +import io.github.thoroldvix.api.YoutubeClient; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; + +/** + * Default implementation of {@link YoutubeClient}. + */ +final class DefaultYoutubeClient implements YoutubeClient { + + private static final String YOUTUBE_REQUEST_FAILED = "Request to YouTube failed."; + + private final HttpClient httpClient; + + DefaultYoutubeClient() { + this.httpClient = HttpClient.newHttpClient(); + } + + DefaultYoutubeClient(HttpClient httpClient) { + this.httpClient = httpClient; + } + + @Override + public String get(String url, Map headers) throws TranscriptRetrievalException { + String videoId = url.split("=")[1]; + String[] headersArray = createHeaders(headers); + HttpRequest request = createRequest(url, headersArray); + HttpResponse response = send(videoId, request); + if (response.statusCode() != 200) { + throw new TranscriptRetrievalException(videoId, YOUTUBE_REQUEST_FAILED + " Status code: " + response.statusCode()); + } + return response.body(); + } + + private HttpResponse send(String videoId, HttpRequest request) throws TranscriptRetrievalException { + try { + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (IOException e) { + throw new TranscriptRetrievalException(videoId, YOUTUBE_REQUEST_FAILED, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new TranscriptRetrievalException(videoId, YOUTUBE_REQUEST_FAILED, e); + } + } + + private static HttpRequest createRequest(String url, String[] headersArray) { + return HttpRequest.newBuilder() + .uri(URI.create(url)) + .headers(headersArray) + .build(); + } + + private String[] createHeaders(Map headers) { + String[] headersArray = new String[headers.size() * 2]; + int i = 0; + for (Map.Entry entry : headers.entrySet()) { + headersArray[i++] = entry.getKey(); + headersArray[i++] = entry.getValue(); + } + return headersArray; + } +} diff --git a/lib/src/main/java/io/github/thoroldvix/internal/DefaultYoutubeTranscriptApi.java b/lib/src/main/java/io/github/thoroldvix/internal/DefaultYoutubeTranscriptApi.java new file mode 100644 index 0000000..29d590d --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/internal/DefaultYoutubeTranscriptApi.java @@ -0,0 +1,145 @@ +package io.github.thoroldvix.internal; + + +import io.github.thoroldvix.api.*; + +import java.io.IOException; +import java.net.HttpCookie; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Default implementation of {@link YoutubeTranscriptApi}. + */ +final class DefaultYoutubeTranscriptApi implements YoutubeTranscriptApi { + + private static final String FAILED_TO_GIVE_COOKIES_CONSENT = "Failed to automatically give consent to saving cookies"; + private static final String YOUTUBE_WATCH_URL = "https://www.youtube.com/watch?v="; + + private final YoutubeClient client; + private final FileLinesReader fileLinesReader; + + DefaultYoutubeTranscriptApi(YoutubeClient client, FileLinesReader fileLinesReader) { + this.client = client; + this.fileLinesReader = fileLinesReader; + } + + @Override + public TranscriptList listTranscriptsWithCookies(String videoId, String cookiesPath) throws TranscriptRetrievalException { + validateVideoId(videoId); + List cookies = loadCookies(videoId, cookiesPath); + String cookieHeader = cookies.stream() + .map(HttpCookie::toString) + .collect(Collectors.joining("; ")); + String videoPageHtml = fetchVideoPageHtml(videoId, cookieHeader); + + return TranscriptListJSON.from(videoPageHtml, client, videoId) + .transcriptList(); + } + + private void validateVideoId(String videoId) { + if (!videoId.matches("[a-zA-Z0-9_-]{11}")) { + throw new IllegalArgumentException("Invalid video id: " + videoId); + } + } + + private List loadCookies(String videoId, String cookiesPath) throws TranscriptRetrievalException { + try { + List cookieLines = fileLinesReader.readLines(cookiesPath); + return cookieLines.stream() + .filter(line -> !line.startsWith("#")) + .map(line -> line.split("\t")) + .filter(parts -> parts.length >= 7) + .map(DefaultYoutubeTranscriptApi::createCookie) + .collect(Collectors.toList()); + } catch (IOException e) { + throw new TranscriptRetrievalException(videoId, String.format("Failed to load cookies from a file: %s.", cookiesPath), e); + } + } + + private static HttpCookie createCookie(String[] parts) { + String domain = parts[0]; + boolean secure = Boolean.parseBoolean(parts[1]); + String path = parts[2]; + boolean httpOnly = Boolean.parseBoolean(parts[3]); + long expiration = Long.parseLong(parts[4]); + String name = parts[5]; + String value = parts[6]; + + HttpCookie cookie = new HttpCookie(name, value); + cookie.setDomain(domain); + cookie.setPath(path); + cookie.setSecure(secure); + cookie.setHttpOnly(httpOnly); + cookie.setMaxAge(expiration); + return cookie; + } + + private String fetchVideoPageHtml(String videoId, String cookieHeader) throws TranscriptRetrievalException { + Map requestHeaders = createRequestHeaders(cookieHeader); + return client.get(YOUTUBE_WATCH_URL + videoId, requestHeaders); + } + + private Map createRequestHeaders(String cookieHeader) { + Map headers = new HashMap<>(); + headers.put("Accept-Language", "en-US"); + if (cookieHeader != null) { + headers.put("Cookie", cookieHeader); + } + return Collections.unmodifiableMap(headers); + } + + @Override + public TranscriptList listTranscripts(String videoId) throws TranscriptRetrievalException { + validateVideoId(videoId); + String videoPageHtml = fetchVideoPageHtml(videoId, null); + if (containsConsentPage(videoPageHtml)) { + videoPageHtml = retryWithConsentCookie(videoId, videoPageHtml); + } + return TranscriptListJSON.from(videoPageHtml, client, videoId) + .transcriptList(); + } + + private static boolean containsConsentPage(String videoPageHtml) { + String consentPagePattern = "action=\"https://consent.youtube.com/s\""; + return videoPageHtml.contains(consentPagePattern); + } + + private String retryWithConsentCookie(String videoId, String videoPageHtml) throws TranscriptRetrievalException { + String consentCookie = extractConsentCookie(videoId, videoPageHtml); + Map requestHeaders = createRequestHeaders(consentCookie); + videoPageHtml = client.get(YOUTUBE_WATCH_URL + videoId, requestHeaders); + if (containsConsentPage(videoPageHtml)) { + throw new TranscriptRetrievalException(videoId, FAILED_TO_GIVE_COOKIES_CONSENT); + } + return videoPageHtml; + } + + @Override + public TranscriptContent getTranscriptWithCookies(String videoId, String cookiesPath, String... languageCodes) throws TranscriptRetrievalException { + return listTranscriptsWithCookies(videoId, cookiesPath) + .findTranscript(languageCodes) + .fetch(); + } + + @Override + public TranscriptContent getTranscript(String videoId, String... languageCodes) throws TranscriptRetrievalException { + return listTranscripts(videoId) + .findTranscript(languageCodes) + .fetch(); + } + + private static String extractConsentCookie(String videoId, String html) throws TranscriptRetrievalException { + Pattern consentCookiePattern = Pattern.compile("name=\"v\" value=\"(.*?)\""); + Matcher matcher = consentCookiePattern.matcher(html); + if (!matcher.find()) { + throw new TranscriptRetrievalException(videoId, FAILED_TO_GIVE_COOKIES_CONSENT); + } + return String.format("CONSENT=YES+%s", matcher.group(1)); + } +} diff --git a/lib/src/main/java/io/github/thoroldvix/internal/FileLinesReader.java b/lib/src/main/java/io/github/thoroldvix/internal/FileLinesReader.java new file mode 100644 index 0000000..c309854 --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/internal/FileLinesReader.java @@ -0,0 +1,19 @@ +package io.github.thoroldvix.internal; + +import java.io.IOException; +import java.util.List; + +/** + * Used for reading lines from a file. + */ +@FunctionalInterface +interface FileLinesReader { + /** + * Reads lines from a file. + * + * @param filePath The path to the file + * @return A list of lines + * @throws IOException If the file could not be read + */ + List readLines(String filePath) throws IOException; +} diff --git a/lib/src/main/java/io/github/thoroldvix/internal/TranscriptApiFactory.java b/lib/src/main/java/io/github/thoroldvix/internal/TranscriptApiFactory.java new file mode 100644 index 0000000..b785ce6 --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/internal/TranscriptApiFactory.java @@ -0,0 +1,35 @@ +package io.github.thoroldvix.internal; + +import io.github.thoroldvix.api.YoutubeClient; +import io.github.thoroldvix.api.YoutubeTranscriptApi; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Class for creating instances of {@link YoutubeTranscriptApi}. + */ +public final class TranscriptApiFactory { + + private TranscriptApiFactory() { + } + + /** + * Creates a new instance of {@link YoutubeTranscriptApi} using the default YouTube client. + * + * @return A new instance of {@link YoutubeTranscriptApi} + */ + public static YoutubeTranscriptApi createDefault() { + return createWithClient(new DefaultYoutubeClient()); + } + + /** + * Creates a new instance of {@link YoutubeTranscriptApi} using the specified {@link YoutubeClient}. + * + * @param client The {@link YoutubeClient} to be used for YouTube interactions + * @return A new instance of {@link YoutubeTranscriptApi} + */ + public static YoutubeTranscriptApi createWithClient(YoutubeClient client) { + return new DefaultYoutubeTranscriptApi(client, filePath -> Files.readAllLines(Path.of(filePath))); + } +} diff --git a/lib/src/main/java/io/github/thoroldvix/internal/TranscriptContentXML.java b/lib/src/main/java/io/github/thoroldvix/internal/TranscriptContentXML.java new file mode 100644 index 0000000..d2eceb3 --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/internal/TranscriptContentXML.java @@ -0,0 +1,68 @@ +package io.github.thoroldvix.internal; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import io.github.thoroldvix.api.TranscriptContent; +import io.github.thoroldvix.api.TranscriptRetrievalException; +import io.github.thoroldvix.internal.DefaultTranscriptContent.Fragment; +import org.apache.commons.text.StringEscapeUtils; + +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Responsible for extracting transcript content from xml. + */ +final class TranscriptContentXML { + + private final XmlMapper xmlMapper; + private final String xml; + private final String videoId; + + TranscriptContentXML(String xml, String videoId) { + this.xmlMapper = new XmlMapper(); + this.xml = xml; + this.videoId = videoId; + } + + TranscriptContent transcriptContent() throws TranscriptRetrievalException { + List fragments = parseFragments(); + List content = formatFragments(fragments); + + return new DefaultTranscriptContent(content); + } + + private static List formatFragments(List fragments) { + return fragments.stream() + .filter(TranscriptContentXML::isValidTranscriptFragment) + .map(TranscriptContentXML::removeHtmlTags) + .map(TranscriptContentXML::unescapeXmlTags) + .collect(Collectors.toList()); + } + + private List parseFragments() throws TranscriptRetrievalException { + try { + return xmlMapper.readValue(xml, new TypeReference<>() { + }); + } catch (JsonProcessingException e) { + throw new TranscriptRetrievalException(videoId, "Failed to parse transcript content XML.", e); + } + } + + private static Fragment unescapeXmlTags(Fragment fragment) { + String formattedValue = StringEscapeUtils.unescapeXml(fragment.getText()); + return new Fragment(formattedValue, fragment.getStart(), fragment.getDur()); + } + + private static Fragment removeHtmlTags(Fragment fragment) { + Pattern pattern = Pattern.compile("<[^>]*>", Pattern.CASE_INSENSITIVE); + String text = pattern.matcher(fragment.getText()).replaceAll(""); + return new Fragment(text, fragment.getStart(), fragment.getDur()); + } + + private static boolean isValidTranscriptFragment(Fragment fragment) { + return fragment.getText() != null && !fragment.getText().isBlank(); + } +} diff --git a/lib/src/main/java/io/github/thoroldvix/internal/TranscriptListJSON.java b/lib/src/main/java/io/github/thoroldvix/internal/TranscriptListJSON.java new file mode 100644 index 0000000..0e904d3 --- /dev/null +++ b/lib/src/main/java/io/github/thoroldvix/internal/TranscriptListJSON.java @@ -0,0 +1,132 @@ +package io.github.thoroldvix.internal; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.thoroldvix.api.Transcript; +import io.github.thoroldvix.api.TranscriptList; +import io.github.thoroldvix.api.TranscriptRetrievalException; +import io.github.thoroldvix.api.YoutubeClient; + +import java.util.Collections; +import java.util.Map; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +/** + * Represents transcript JSON which is extracted from a video page HTML. + * It contains methods for retrieving transcript data from JSON. + */ +final class TranscriptListJSON { + + private static final String TOO_MANY_REQUESTS = "YouTube is receiving too many requests from this IP and now requires solving a captcha to continue. " + + "One of the following things can be done to work around this:\n" + + "- Manually solve the captcha in a browser and export the cookie. " + + "Read here how to use that cookie with " + + "youtube-transcript-api: https://github.com/thoroldvix/youtube-transcript-api#cookies\n" + + "- Use a different IP address\n" + + "- Wait until the ban on your IP has been lifted"; + private static final String TRANSCRIPTS_DISABLED = "Transcripts are disabled for this video."; + + private final JsonNode json; + private final YoutubeClient client; + private final String videoId; + + private TranscriptListJSON(JsonNode json, YoutubeClient client, String videoId) { + this.json = json; + this.client = client; + this.videoId = videoId; + } + + TranscriptList transcriptList() { + return new DefaultTranscriptList(videoId, getManualTranscripts(), getGeneratedTranscripts(), getTranslationLanguages()); + } + + static TranscriptListJSON from(String videoPageHtml, YoutubeClient client, String videoId) throws TranscriptRetrievalException { + String json = getJsonFromHtml(videoPageHtml, videoId); + JsonNode parsedJson = parseJson(json, videoId); + checkIfTranscriptsDisabled(videoId, parsedJson); + return new TranscriptListJSON(parsedJson, client, videoId); + } + + private static String getJsonFromHtml(String videoPageHtml, String videoId) throws TranscriptRetrievalException { + String[] splitHtml = videoPageHtml.split("\"captions\":"); + checkIfHtmlContainsJson(videoPageHtml, videoId, splitHtml); + return splitHtml[1].split(",\"videoDetails")[0].replace("\n", ""); + } + + private static void checkIfHtmlContainsJson(String videoPageHtml, String videoId, String[] splitHtml) throws TranscriptRetrievalException { + //no captions json in html + if (splitHtml.length <= 1) { + //recaptcha + if (videoPageHtml.contains("class=\"g-recaptcha\"")) { + throw new TranscriptRetrievalException(videoId, TOO_MANY_REQUESTS); + } + //non playable + if (!videoPageHtml.contains("\"playabilityStatus\":")) { + throw new TranscriptRetrievalException(videoId, "This video is no longer available."); + } + throw new TranscriptRetrievalException(videoId, TRANSCRIPTS_DISABLED); + } + } + + private static JsonNode parseJson(String json, String videoId) throws TranscriptRetrievalException { + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode parsedJson; + try { + parsedJson = objectMapper.readTree(json).get("playerCaptionsTracklistRenderer"); + } catch (JsonProcessingException e) { + throw new TranscriptRetrievalException(videoId, "Failed to parse transcript JSON.", e); + } + return parsedJson; + } + + private static void checkIfTranscriptsDisabled(String videoId, JsonNode parsedJson) throws TranscriptRetrievalException { + if (parsedJson == null) { + throw new TranscriptRetrievalException(videoId, TRANSCRIPTS_DISABLED); + } + if (!parsedJson.has("captionTracks")) { + throw new TranscriptRetrievalException(videoId, TRANSCRIPTS_DISABLED); + } + } + + private Map getTranslationLanguages() { + if (!json.has("translationLanguages")) { + return Collections.emptyMap(); + } + return StreamSupport.stream(json.get("translationLanguages").spliterator(), false) + .collect(Collectors.toMap( + jsonNode -> jsonNode.get("languageCode").asText(), + jsonNode -> jsonNode.get("languageName").get("simpleText").asText() + )); + } + + private Map getManualTranscripts() { + return getTranscripts(client, jsonNode -> !jsonNode.has("kind")); + } + + private Map getGeneratedTranscripts() { + return getTranscripts(client, jsonNode -> jsonNode.has("kind")); + } + + private Map getTranscripts(YoutubeClient client, Predicate filter) { + Map translationLanguages = getTranslationLanguages(); + return StreamSupport.stream(json.get("captionTracks").spliterator(), false) + .filter(filter) + .map(jsonNode -> getTranscript(client, jsonNode, translationLanguages)) + .collect(Collectors.toMap(Transcript::getLanguageCode, transcript -> transcript)); + } + + private Transcript getTranscript(YoutubeClient client, JsonNode jsonNode, Map translationLanguages) { + return new DefaultTranscript( + client, + videoId, + jsonNode.get("baseUrl").asText(), + jsonNode.get("name").get("simpleText").asText(), + jsonNode.get("languageCode").asText(), + jsonNode.has("kind"), + translationLanguages + ); + } +} diff --git a/lib/src/test/java/io/github/thoroldvix/TranscriptRetrievalExceptionTest.java b/lib/src/test/java/io/github/thoroldvix/TranscriptRetrievalExceptionTest.java new file mode 100644 index 0000000..d22ff38 --- /dev/null +++ b/lib/src/test/java/io/github/thoroldvix/TranscriptRetrievalExceptionTest.java @@ -0,0 +1,20 @@ +package io.github.thoroldvix; + +import io.github.thoroldvix.api.TranscriptRetrievalException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TranscriptRetrievalExceptionTest { + + @Test + void exceptionMessageBuiltCorrectly() { + TranscriptRetrievalException exception = new TranscriptRetrievalException("dQw4w9WgXcQ", "Cause"); + + String expected = "Could not retrieve transcript for the video: https://www.youtube.com/watch?v=dQw4w9WgXcQ.\nReason: Cause"; + + String actual = exception.getMessage(); + + assertThat(actual).isEqualTo(expected); + } +} \ No newline at end of file diff --git a/lib/src/test/java/io/github/thoroldvix/internal/DefaultTranscriptContentTest.java b/lib/src/test/java/io/github/thoroldvix/internal/DefaultTranscriptContentTest.java new file mode 100644 index 0000000..d5fd24e --- /dev/null +++ b/lib/src/test/java/io/github/thoroldvix/internal/DefaultTranscriptContentTest.java @@ -0,0 +1,33 @@ +package io.github.thoroldvix.internal; + +import io.github.thoroldvix.api.TranscriptContent; +import io.github.thoroldvix.internal.DefaultTranscriptContent.Fragment; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class DefaultTranscriptContentTest { + + private TranscriptContent transcriptContent; + + @BeforeEach + void setUp() { + List fragments = List.of(new DefaultTranscriptContent.Fragment("Hey, this is just a test", 0.0, 1.54), + new DefaultTranscriptContent.Fragment("this is not the original transcript", 1.54, 4.16), + new DefaultTranscriptContent.Fragment("test & test, like this \"test\" he's testing", 5.7, 3.239)); + transcriptContent = new DefaultTranscriptContent(fragments); + } + + @Test + void toStringFormattedCorrectly() { + String expected = """ + content=[{text='Hey, this is just a test', start=0.0, dur=1.54},\ + {text='this is not the original transcript', start=1.54, dur=4.16},\ + {text='test & test, like this "test" he's testing', start=5.7, dur=3.239}]"""; + + assertThat(transcriptContent.toString()).isEqualToNormalizingNewlines(expected); + } +} diff --git a/lib/src/test/java/io/github/thoroldvix/internal/DefaultTranscriptListTest.java b/lib/src/test/java/io/github/thoroldvix/internal/DefaultTranscriptListTest.java new file mode 100644 index 0000000..7ea02e2 --- /dev/null +++ b/lib/src/test/java/io/github/thoroldvix/internal/DefaultTranscriptListTest.java @@ -0,0 +1,184 @@ +package io.github.thoroldvix.internal; + +import io.github.thoroldvix.api.Transcript; +import io.github.thoroldvix.api.TranscriptList; +import io.github.thoroldvix.api.TranscriptRetrievalException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DefaultTranscriptListTest { + + private TranscriptList transcriptList; + + @BeforeEach + void setUp() { + Map manualTranscripts = Map.of( + "de", createTranscript("Deutsch", "de", false), + "cs", createTranscript("cs", "cs", false) + ); + Map generatedTranscripts = Map.of( + "en", createTranscript("English", "en", true) + ); + transcriptList = new DefaultTranscriptList("dQw4w9WgXcQ", manualTranscripts, generatedTranscripts, Map.of("af", "Afrikaans")); + } + + private Transcript createTranscript(String language, String languageCode, boolean isGenerated) { + return new DefaultTranscript( + null, + "dQw4w9WgXcQ", + null, + language, + languageCode, + isGenerated, + null); + } + + @Test + void findTranscriptNoCodeUsesEnglish() throws Exception { + Transcript transcript = transcriptList.findTranscript(); + + assertThat(transcript.getLanguageCode()).isEqualTo("en"); + } + + @Test + void findTranscriptSingleLanguageCode() throws Exception { + Transcript transcript = transcriptList.findTranscript("de"); + + assertThat(transcript.getLanguageCode()).isEqualTo("de"); + } + + @Test + void findTranscriptMultipleLanguageCodesFirstCodeIsUsed() throws Exception { + Transcript transcript = transcriptList.findTranscript("de", "en"); + + assertThat(transcript.getLanguageCode()).isEqualTo("de"); + } + + @Test + void findTranscriptMultipleGetLanguageCodesSecondCodeIsUsedIfFirstCodeNotAvailable() throws Exception { + Transcript transcript = transcriptList.findTranscript("zz", "en"); + + assertThat(transcript.getLanguageCode()).isEqualTo("en"); + } + + @Test + void findTranscriptFindsManuallyCreated() throws Exception { + Transcript manuallyCreatedTranscript = transcriptList.findTranscript("cs"); + + assertThat(manuallyCreatedTranscript.getLanguageCode()).isEqualTo("cs"); + assertThat(manuallyCreatedTranscript.isGenerated()).isFalse(); + } + + @Test + void findTranscriptFindsGenerated() throws Exception { + Transcript generatedTranscript = transcriptList.findTranscript("en"); + + assertThat(generatedTranscript.getLanguageCode()).isEqualTo("en"); + assertThat(generatedTranscript.isGenerated()).isTrue(); + } + + @Test + void findTranscriptThrowsExceptionWhenLanguageNotAvailable() { + assertThatThrownBy(() -> transcriptList.findTranscript("zz")) + .isInstanceOf(TranscriptRetrievalException.class); + } + + @ParameterizedTest + @NullAndEmptySource + void findTranscriptWhenInvalidGetLanguageCodesThrowsException(String languageCodes) { + assertThatThrownBy(() -> transcriptList.findTranscript(languageCodes)) + .isInstanceOf(IllegalArgumentException.class); + + } + + @Test + void findManuallyCreated() throws Exception { + Transcript transcript = transcriptList.findManualTranscript("cs"); + + assertThat(transcript.getLanguageCode()).isEqualTo("cs"); + assertThat(transcript.isGenerated()).isFalse(); + } + + @Test + void findManuallyCreatedNoCodeUsesEnglish() throws Exception { + TranscriptList transcriptList = new DefaultTranscriptList("dQw4w9WgXcQ", + Map.of("en", createTranscript("English", "en", false), + "de", createTranscript("Deutsch", "de", false)), + Map.of(), + Map.of()); + + Transcript transcript = transcriptList.findManualTranscript(); + + assertThat(transcript.getLanguageCode()).isEqualTo("en"); + assertThat(transcript.isGenerated()).isFalse(); + } + + @ParameterizedTest + @NullAndEmptySource + void findManuallyCreatedWhenInvalidGetLanguageCodesThrowsException(String languageCodes) { + assertThatThrownBy(() -> transcriptList.findManualTranscript(languageCodes)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void findGenerated() throws Exception { + assertThatThrownBy(() -> transcriptList.findGeneratedTranscript("cs")) + .isInstanceOf(TranscriptRetrievalException.class); + + Transcript transcript = transcriptList.findGeneratedTranscript("en"); + + assertThat(transcript.getLanguageCode()).isEqualTo("en"); + assertThat(transcript.isGenerated()).isTrue(); + } + + @Test + void findGeneratedNoCodeUsesEnglish() throws Exception { + TranscriptList transcriptList = new DefaultTranscriptList("dQw4w9WgXcQ", + Map.of(), + Map.of("en", createTranscript("English", "en", true), + "de", createTranscript("Deutsch", "de", true)), + Map.of()); + Transcript transcript = transcriptList.findGeneratedTranscript(); + + assertThat(transcript.getLanguageCode()).isEqualTo("en"); + assertThat(transcript.isGenerated()).isTrue(); + } + + @ParameterizedTest + @NullAndEmptySource + void findGeneratedWithInvalidGetLanguageCodesThrowsException(String languageCodes) { + assertThatThrownBy(() -> transcriptList.findGeneratedTranscript(languageCodes)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void toStringFormattedCorrectly() { + Map manualTranscripts = Map.of( + "en", createTranscript("English", "en", false), + "de", createTranscript("Deutsch", "de", false)); + Map generatedTranscripts = Map.of( + "af", createTranscript("Afrikaans", "af", true), + "cs", createTranscript("Czech", "cs", true)); + Map translationLanguages = Map.of("en", "English", "de", "Deutsch"); + TranscriptList transcriptList = new DefaultTranscriptList( + "dQw4w9WgXcQ", + manualTranscripts, + generatedTranscripts, + translationLanguages); + + String expected = """ + For video with ID (dQw4w9WgXcQ) transcripts are available in the following languages: + Manually created: [de, en] + Automatically generated: [af, cs] + Available translation languages: [de, en]"""; + + assertThat(transcriptList.toString()).isEqualToNormalizingNewlines(expected); + } +} \ No newline at end of file diff --git a/lib/src/test/java/io/github/thoroldvix/internal/DefaultTranscriptTest.java b/lib/src/test/java/io/github/thoroldvix/internal/DefaultTranscriptTest.java new file mode 100644 index 0000000..e0acd4d --- /dev/null +++ b/lib/src/test/java/io/github/thoroldvix/internal/DefaultTranscriptTest.java @@ -0,0 +1,120 @@ +package io.github.thoroldvix.internal; + +import io.github.thoroldvix.api.Transcript; +import io.github.thoroldvix.api.TranscriptContent; +import io.github.thoroldvix.api.TranscriptRetrievalException; +import io.github.thoroldvix.api.YoutubeClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DefaultTranscriptTest { + + private YoutubeClient youtubeClient; + private Transcript transcript; + + @BeforeEach + void setUp() { + youtubeClient = mock(YoutubeClient.class); + transcript = new DefaultTranscript( + youtubeClient, + "dQw4w9WgXcQ", + "https://www.youtube.com/api/timedtext?v=dQw4w9WgXcQ", + "English", + "en", + false, + Map.of("af", "Afrikaans") + ); + } + + @Test + void fetchesTranscriptContent() throws Exception { + String transcriptXml = Files.readString(Path.of("src/test/resources/transcript.xml")); + when(youtubeClient.get(transcript.getApiUrl(), Map.of("Accept-Language", "en-US"))).thenReturn(transcriptXml); + + List expected = List.of(new DefaultTranscriptContent.Fragment("Hey, this is just a test", 0.0, 1.54), + new DefaultTranscriptContent.Fragment("this is not the original transcript", 1.54, 4.16), + new DefaultTranscriptContent.Fragment("test & test, like this \"test\" he's testing", 5.7, 3.239)); + + TranscriptContent actual = transcript.fetch(); + + assertThat(actual.getContent()).isEqualTo(expected); + } + + @Test + void translatesTranscript() throws Exception { + Transcript translatedTranscript = transcript.translate("af"); + + assertThat(translatedTranscript.getLanguageCode()).isEqualTo("af"); + assertThat(translatedTranscript.getApiUrl()).contains("&tlang=af"); + } + + @Test + void translateTranscriptTranslationLanguageNotAvailable() { + assertThatThrownBy(() -> transcript.translate("zz")) + .isInstanceOf(TranscriptRetrievalException.class); + } + + @Test + void isTranslatableGivesCorrectResult() { + Transcript notTranslatableTranscript = new DefaultTranscript( + youtubeClient, + "dQw4w9WgXcQ", + "https://www.youtube.com/api/timedtext?v=dQw4w9WgXcQ", + "English", + "en", + false, + Collections.emptyMap() + ); + assertThat(transcript.isTranslatable()).isTrue(); + assertThat(notTranslatableTranscript.isTranslatable()).isFalse(); + } + + @Test + void translateTranscriptThrowsExceptionWhenNotTranslatable() { + Transcript transcript = new DefaultTranscript( + youtubeClient, + "dQw4w9WgXcQ", + "https://www.youtube.com/api/timedtext?v=dQw4w9WgXcQ", + "English", + "en", + false, + Collections.emptyMap() + ); + + assertThatThrownBy(() -> transcript.translate("af")) + .isInstanceOf(TranscriptRetrievalException.class); + } + + @Test + void toStringFormattedCorrectly() { + Transcript transcript = new DefaultTranscript( + youtubeClient, + "dQw4w9WgXcQ", + "https://www.youtube.com/api/timedtext?v=dQw4w9WgXcQ", + "English", + "en", + false, + Map.of("en", "English", "af", "Afrikaans") + ); + + String expected = """ + Transcript for video with id: dQw4w9WgXcQ. + Language: English + Language code: en + API URL for retrieving content: https://www.youtube.com/api/timedtext?v=dQw4w9WgXcQ + Available translation languages: [af, en]"""; + + assertThat(transcript.toString()).isEqualTo(expected); + } +} \ No newline at end of file diff --git a/lib/src/test/java/io/github/thoroldvix/internal/DefaultYoutubeClientTest.java b/lib/src/test/java/io/github/thoroldvix/internal/DefaultYoutubeClientTest.java new file mode 100644 index 0000000..9fd18ff --- /dev/null +++ b/lib/src/test/java/io/github/thoroldvix/internal/DefaultYoutubeClientTest.java @@ -0,0 +1,94 @@ +package io.github.thoroldvix.internal; + +import io.github.thoroldvix.api.TranscriptRetrievalException; +import io.github.thoroldvix.api.YoutubeClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings("unchecked") +class DefaultYoutubeClientTest { + + private static final String VIDEO_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + private static final Map HEADERS = Map.of("Accept-Language", "en-US"); + + @Mock + private HttpResponse response; + @Mock + private HttpClient httpClient; + @Captor + private ArgumentCaptor requestCaptor; + + private YoutubeClient youtubeClient; + + @BeforeEach + void setUp() { + youtubeClient = new DefaultYoutubeClient(httpClient); + } + + private void givenResponse(String expected) throws IOException, InterruptedException { + when(httpClient.send(requestCaptor.capture(), any(HttpResponse.BodyHandlers.ofString().getClass()))).thenReturn(response); + when(response.statusCode()).thenReturn(200); + when(response.body()).thenReturn(expected); + } + + @Test + void get() throws Exception { + String expected = ""; + givenResponse(expected); + + String actual = youtubeClient.get(VIDEO_URL, Map.of("Accept-Language", "en-US", "Cookie", "test")); + + HttpRequest request = requestCaptor.getValue(); + + assertThat(actual).isEqualTo(expected); + assertThat(request.uri()).isEqualTo(URI.create(VIDEO_URL)); + assertThat(request.headers().map().get("Cookie")).contains("test"); + assertThat(request.headers().map().get("Accept-Language")).contains("en-US"); + } + + @ParameterizedTest + @ValueSource(ints = {500, 404}) + void getThrowsExceptionIfResponseIsNotOk(int statusCode) throws Exception { + when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandlers.ofString().getClass()))).thenReturn(response); + when(response.statusCode()).thenReturn(statusCode); + + assertThatThrownBy(() -> youtubeClient.get(VIDEO_URL, HEADERS)) + .isInstanceOf(TranscriptRetrievalException.class); + } + + @Test + void getThrowsExceptionWhenIOExceptionOccurs() throws Exception { + when(httpClient.send(any(), any())).thenThrow(new IOException()); + + assertThatThrownBy(() -> youtubeClient.get(VIDEO_URL, HEADERS)) + .isInstanceOf(TranscriptRetrievalException.class); + } + + @Test + void getThrowsExceptionWhenInterruptedExceptionOccurs() throws Exception { + when(httpClient.send(any(), any())).thenThrow(new InterruptedException()); + + assertThatThrownBy(() -> youtubeClient.get(VIDEO_URL, HEADERS)) + .isInstanceOf(TranscriptRetrievalException.class); + } +} \ No newline at end of file diff --git a/lib/src/test/java/io/github/thoroldvix/internal/DefaultYoutubeTranscriptApiTest.java b/lib/src/test/java/io/github/thoroldvix/internal/DefaultYoutubeTranscriptApiTest.java new file mode 100644 index 0000000..0e4604b --- /dev/null +++ b/lib/src/test/java/io/github/thoroldvix/internal/DefaultYoutubeTranscriptApiTest.java @@ -0,0 +1,239 @@ +package io.github.thoroldvix.internal; + + +import io.github.thoroldvix.api.*; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DefaultYoutubeTranscriptApiTest { + + private static final String YOUTUBE_WATCH_URL = "https://www.youtube.com/watch?v="; + private static final String RESOURCE_PATH = "src/test/resources/"; + private static final String VIDEO_ID = "dQw4w9WgXcQ"; + + private static String YOUTUBE_HTML; + private static String TRANSCRIPT_XML; + private static String CONSENT_PAGE_HTML; + + private YoutubeClient client; + private FileLinesReader fileLinesReader; + private YoutubeTranscriptApi youtubeTranscriptApi; + + @BeforeAll + static void beforeAll() throws IOException { + YOUTUBE_HTML = Files.readString(Path.of(RESOURCE_PATH, "pages/youtube.html.static")); + TRANSCRIPT_XML = Files.readString(Path.of(RESOURCE_PATH, "transcript.xml")); + CONSENT_PAGE_HTML = Files.readString(Path.of(RESOURCE_PATH, "pages/youtube_consent_page.html.static")); + } + + @BeforeEach + void setUp() { + client = mock(YoutubeClient.class); + fileLinesReader = Mockito.mock(FileLinesReader.class); + youtubeTranscriptApi = new DefaultYoutubeTranscriptApi(client, fileLinesReader); + } + + private void givenVideoPageHtml(String html) throws Exception { + when(client.get(anyString(), anyMap())).thenReturn(html); + } + + private void givenVideoPageHtmlFromFile(String fileName) throws Exception { + String html = Files.readString(Path.of(RESOURCE_PATH, fileName)); + givenVideoPageHtml(html); + } + + @Test + void listTranscripts() throws Exception { + when(client.get(YOUTUBE_WATCH_URL + VIDEO_ID, Map.of("Accept-Language", "en-US"))) + .thenReturn(YOUTUBE_HTML); + + TranscriptList transcriptList = youtubeTranscriptApi.listTranscripts(VIDEO_ID); + + assertThat(transcriptList) + .map(Transcript::getLanguageCode) + .containsExactlyInAnyOrder("zh", "de", "hi", "ja", "ko", "es", "cs", "en"); + } + + @Test + void listTranscriptsGivenVideoPageWithInvalidCaptionsJsonThrowsException() throws Exception { + givenVideoPageHtmlFromFile("pages/youtube_malformed_captions_json.html.static"); + + assertThatThrownBy(() -> youtubeTranscriptApi.listTranscripts(VIDEO_ID)) + .isInstanceOf(TranscriptRetrievalException.class); + } + + @Test + void listTranscriptsNoTranslations() throws Exception { + givenVideoPageHtmlFromFile("pages/youtube_no_translation.html.static"); + + TranscriptList transcriptList = youtubeTranscriptApi.listTranscripts(VIDEO_ID); + + for (Transcript transcript : transcriptList) { + assertThat(transcript.getTranslationLanguages()).isEmpty(); + } + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "short", "with spaces", "over11characterslong"}) + void listTranscriptsGivenInvalidVideoIdThrowsException(String invalidId) { + assertThatThrownBy(() -> youtubeTranscriptApi.listTranscripts(invalidId)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void getTranscript() throws Exception { + when(client.get(anyString(), anyMap())) + .thenReturn(YOUTUBE_HTML) + .thenReturn(TRANSCRIPT_XML); + + TranscriptContent expected = getTranscriptContent(); + + TranscriptContent actual = youtubeTranscriptApi.getTranscript(VIDEO_ID); + + assertThat(actual).isEqualTo(expected); + } + + private static DefaultTranscriptContent getTranscriptContent() { + return new DefaultTranscriptContent(List.of(new DefaultTranscriptContent.Fragment("Hey, this is just a test", 0.0, 1.54), + new DefaultTranscriptContent.Fragment("this is not the original transcript", 1.54, 4.16), + new DefaultTranscriptContent.Fragment("test & test, like this \"test\" he's testing", 5.7, 3.239))); + } + + @Test + void getTranscriptCreatesConsentCookieIfNeededAndRetries() throws Exception { + when(client.get(anyString(), anyMap())) + .thenReturn(CONSENT_PAGE_HTML) + .thenReturn(YOUTUBE_HTML) + .thenReturn(TRANSCRIPT_XML); + + youtubeTranscriptApi.getTranscript(VIDEO_ID); + + verify(client).get(anyString(), eq(Map.of("Accept-Language", "en-US", + "Cookie", "CONSENT=YES+cb.20210328-17-p0.de+FX+119"))); + } + + @Test + void getTranscriptThrowsExceptionWhenConsentCookieCreationFailed() throws Exception { + givenVideoPageHtml(CONSENT_PAGE_HTML); + + assertThatThrownBy(() -> youtubeTranscriptApi.getTranscript(VIDEO_ID)) + .isInstanceOf(TranscriptRetrievalException.class); + } + + @Test + void getTranscriptThrowsExceptionWhenConsentCookieAgeInvalid() throws Exception { + givenVideoPageHtmlFromFile("pages/youtube_consent_page_invalid.html.static"); + + assertThatThrownBy(() -> youtubeTranscriptApi.getTranscript(VIDEO_ID)) + .isInstanceOf(TranscriptRetrievalException.class); + } + + @Test + void getTranscriptThrowsExceptionWhenVideoUnavailable() throws Exception { + givenVideoPageHtmlFromFile("pages/youtube_video_unavailable.html.static"); + + assertThatThrownBy(() -> youtubeTranscriptApi.getTranscript(VIDEO_ID)) + .isInstanceOf(TranscriptRetrievalException.class); + } + + @Test + void getTranscriptThrowsExceptionWhenYoutubeRequestLimitReached() throws Exception { + givenVideoPageHtmlFromFile("pages/youtube_too_many_requests.html.static"); + + assertThatThrownBy(() -> youtubeTranscriptApi.getTranscript(VIDEO_ID)) + .isInstanceOf(TranscriptRetrievalException.class); + } + + @Test + void getTranscriptThrowsExceptionGivenInvalidTranscriptXML() throws Exception { + when(client.get(anyString(), anyMap())) + .thenReturn(YOUTUBE_HTML) + .thenReturn("invalid xml"); + + assertThatThrownBy(() -> youtubeTranscriptApi.getTranscript(VIDEO_ID)) + .isInstanceOf(TranscriptRetrievalException.class); + } + + @Test + void getTranscriptThrowsExceptionWhenYoutubeRequestFailed() throws Exception { + when(client.get(anyString(), anyMap())).thenThrow(TranscriptRetrievalException.class); + + assertThatThrownBy(() -> youtubeTranscriptApi.getTranscript(VIDEO_ID)) + .isInstanceOf(TranscriptRetrievalException.class); + } + + @Test + void getTranscriptThrowsExceptionWhenTranscriptsDisabled() throws Exception { + givenVideoPageHtmlFromFile("pages/youtube_transcripts_disabled.html.static"); + + assertThatThrownBy(() -> youtubeTranscriptApi.getTranscript(VIDEO_ID)) + .isInstanceOf(TranscriptRetrievalException.class); + + givenVideoPageHtmlFromFile("pages/youtube_transcripts_disabled2.html.static"); + + assertThatThrownBy(() -> youtubeTranscriptApi.getTranscript(VIDEO_ID)) + .isInstanceOf(TranscriptRetrievalException.class); + } + + @Test + void getTranscriptThrowsExceptionWhenLanguageUnavailable() throws Exception { + givenVideoPageHtml(YOUTUBE_HTML); + + assertThatThrownBy(() -> youtubeTranscriptApi.getTranscript(VIDEO_ID, "cz")) + .isInstanceOf(TranscriptRetrievalException.class); + } + + @Test + void getTranscriptThrowsExceptionWhenNoTranscriptAvailable() throws Exception { + givenVideoPageHtmlFromFile("pages/youtube_no_transcript_available.html.static"); + + + assertThatThrownBy(() -> youtubeTranscriptApi.getTranscript(VIDEO_ID)) + .isInstanceOf(TranscriptRetrievalException.class); + } + + @Test + void getTranscriptWithCookies() throws Exception { + List lines = Files.readAllLines(Path.of(RESOURCE_PATH, "example_cookies.txt")); + + when(client.get(anyString(), anyMap())) + .thenReturn(YOUTUBE_HTML) + .thenReturn(TRANSCRIPT_XML); + when(fileLinesReader.readLines(anyString())).thenReturn(lines); + + TranscriptContent expected = getTranscriptContent(); + + TranscriptContent actual = youtubeTranscriptApi.getTranscriptWithCookies(VIDEO_ID, "cookiePath"); + + assertThat(actual).isEqualTo(expected); + verify(client).get(anyString(), eq(Map.of("Accept-Language", "en-US", "Cookie", "TEST_FIELD=\"TEST_VALUE\";$Path=\"/\";$Domain=\".example.com\""))); + + } + + @Test + void getTranscriptWithCookiesWhenCannotReadCookiesFileThrowsException() throws Exception { + when(fileLinesReader.readLines(anyString())).thenThrow(IOException.class); + + assertThatThrownBy(() -> youtubeTranscriptApi.getTranscriptWithCookies(VIDEO_ID, "cookiePath")) + .isInstanceOf(TranscriptRetrievalException.class); + } +} \ No newline at end of file diff --git a/lib/src/test/java/io/github/thoroldvix/internal/TranscriptFormattersTest.java b/lib/src/test/java/io/github/thoroldvix/internal/TranscriptFormattersTest.java new file mode 100644 index 0000000..b8dafae --- /dev/null +++ b/lib/src/test/java/io/github/thoroldvix/internal/TranscriptFormattersTest.java @@ -0,0 +1,163 @@ +package io.github.thoroldvix.internal; + +import io.github.thoroldvix.api.TranscriptContent; +import io.github.thoroldvix.api.TranscriptFormatter; +import io.github.thoroldvix.api.TranscriptFormatters; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TranscriptFormattersTest { + + private static final DefaultTranscriptContent EMPTY_CONTENT = new DefaultTranscriptContent(List.of()); + private TranscriptContent content; + + @BeforeEach + void setUp() { + List fragments = List.of(new DefaultTranscriptContent.Fragment("Hey, this is just a test", 0.0, 1.54), + new DefaultTranscriptContent.Fragment("this is not the original transcript", 1.54, 4.16), + new DefaultTranscriptContent.Fragment("test & test, like this \"test\" he's testing", 5.7, 3.239)); + content = new DefaultTranscriptContent(fragments); + } + + @Test + void jsonFormatter() { + TranscriptFormatter transcriptFormatter = TranscriptFormatters.jsonFormatter(); + String expected = """ + {"content":[{"text":"Hey, this is just a test","start":0.0,"dur":1.54},\ + {"text":"this is not the original transcript","start":1.54,"dur":4.16},\ + {"text":"test & test, like this \\"test\\" he's testing","start":5.7,"dur":3.239}]}\ + """; + + String actual = transcriptFormatter.format(content); + + assertThat(actual).isEqualToNormalizingNewlines(expected); + } + + @Test + void jsonFormatterNoContent() { + TranscriptFormatter transcriptFormatter = TranscriptFormatters.jsonFormatter(); + + String expected = "{\"content\":[]}"; + + String actual = transcriptFormatter.format(EMPTY_CONTENT); + + assertThat(actual).isEqualTo(expected); + } + + @Test + void jsonPrettyFormatter() { + TranscriptFormatter transcriptFormatter = TranscriptFormatters.prettyJsonFormatter(); + + String expected = """ + { + "content" : [ { + "text" : "Hey, this is just a test", + "start" : 0.0, + "dur" : 1.54 + }, { + "text" : "this is not the original transcript", + "start" : 1.54, + "dur" : 4.16 + }, { + "text" : "test & test, like this \\"test\\" he's testing", + "start" : 5.7, + "dur" : 3.239 + } ] + }"""; + + String actual = transcriptFormatter.format(content); + + assertThat(actual).isEqualToNormalizingNewlines(expected); + } + + @Test + void jsonPrettyFormatterNoContent() { + TranscriptFormatter transcriptFormatter = TranscriptFormatters.prettyJsonFormatter(); + + String expected = "{\n \"content\" : [ ]\n}"; + + String actual = transcriptFormatter.format(EMPTY_CONTENT); + + assertThat(actual).isEqualToNormalizingNewlines(expected); + } + + @Test + void textFormatter() { + TranscriptFormatter transcriptFormatter = TranscriptFormatters.textFormatter(); + + String expected = "Hey, this is just a test\nthis is not the original transcript\ntest & test, like this \"test\" he's testing"; + + String actual = transcriptFormatter.format(content); + + assertThat(actual).isEqualTo(expected); + } + + @Test + void webVTTFormatter() { + TranscriptFormatter transcriptFormatter = TranscriptFormatters.webVTTFormatter(); + + String expected = """ + WEBVTT + + 00:00:00.000 --> 00:00:01.540 + Hey, this is just a test + + 00:00:01.540 --> 00:00:05.700 + this is not the original transcript + + 00:00:05.700 --> 00:00:08.939 + test & test, like this "test" he's testing"""; + + String actual = transcriptFormatter.format(content); + + assertThat(actual).isEqualToNormalizingNewlines(expected); + } + + @Test + void webVTTFormatterNoContent() { + TranscriptFormatter transcriptFormatter = TranscriptFormatters.webVTTFormatter(); + + String expected = "WEBVTT\n\n"; + + String actual = transcriptFormatter.format(EMPTY_CONTENT); + + assertThat(actual).isEqualToNormalizingNewlines(expected); + } + + @Test + void srtFormatter() { + TranscriptFormatter transcriptFormatter = TranscriptFormatters.srtFormatter(); + + String expected = """ + 1 + 00:00:00.000 --> 00:00:01.540 + Hey, this is just a test + + 2 + 00:00:01.540 --> 00:00:05.700 + this is not the original transcript + + 3 + 00:00:05.700 --> 00:00:08.939 + test & test, like this "test" he's testing"""; + + String actual = transcriptFormatter.format(content); + + assertThat(actual).isEqualToNormalizingNewlines(expected); + } + + @Test + void srtFormatterNoContent() { + TranscriptFormatter transcriptFormatter = TranscriptFormatters.srtFormatter(); + + String expected = ""; + + String actual = transcriptFormatter.format(EMPTY_CONTENT); + + assertThat(actual).isEqualTo(expected); + } +} diff --git a/lib/src/test/resources/example_cookies.txt b/lib/src/test/resources/example_cookies.txt new file mode 100644 index 0000000..203c1f9 --- /dev/null +++ b/lib/src/test/resources/example_cookies.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# http://curl.haxx.se/rfc/cookie_spec.html +# This is a generated file! Do not edit. + +.example.com TRUE / TRUE 3594431874 TEST_FIELD TEST_VALUE diff --git a/lib/src/test/resources/pages/youtube.html.static b/lib/src/test/resources/pages/youtube.html.static new file mode 100644 index 0000000..03c3f6b --- /dev/null +++ b/lib/src/test/resources/pages/youtube.html.static @@ -0,0 +1,4215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Surface Go Review - It’s Awesome - YouTube + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+ + +
+
+ +
+
+ +
+ DE +
+
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+

+ + + +Wird geladen... + +

+ +
+
+
+ +
+
+
+
+
+
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

+ + + + + Surface Go Review - Es ist Awesome + + +

+
+
+ + +
+
+ + + + + +
+ + +
+
+
+
+
1.597.128 Aufrufe
+
+
+
+
+
+ + + +
+
+
+ + +
+
+
+
+

+ + + +Wird geladen... + +

+ +
+
+
+ +
+ +
+
+
+

+ + + +Wird geladen... + +

+ +
+
+
+

+ Transkript +

+
+ +
+ + +
+
+ Das interaktive Transkript konnte nicht geladen werden. +
+ + +
+
+ +
+ +
+
+

+ + + +Wird geladen... + +

+ +
+
+ + +
+
+ Die Bewertungsfunktion ist nach Ausleihen des Videos + verfügbar. +
+ +
+ +
+
+ Diese Funktion ist gerade nicht verfügbar. Bitte versuche es später noch + einmal. +
+
+ + +
+ + +
+ + +
+
+
+
+
Am + 02.08.2018 veröffentlicht +
+
+

Dave2D-Rezension des Microsoft + Surface Go. Dies ist die beste 2 + in einem Laptop von Microsoft für Studenten mit einem strafferen + Budget.
Zum Verkauf hier-https://amzn.to/2n3Y4sj

Dieser + 2in1 tablet/Laptop ist unglaublich klein und hat eine Tonne + Potenzial für Menschen, die ein ultratragbares Gerät benötigen, + das sowohl als komfortables Tablet als auch als sehr + funktionstüchtig eingesetzt werden kann. Das ist toll für + Entwickler, Studenten, Arbeit oder auch für den Medienkonsum als + Sekundärgerät.

Musik-Credits:
Fili-Sonntag + Vibez

Folge mir:
http://twitter.com/Dave2D
http://www.instagram.com/Dave2D +

+
+
+ +
+
+
+
+ + +
+ + +
+
+

+ + + +Wird geladen... + +

+ +
+ +
+ + +
+
+
+ + +
+
+ +
+ +
+
+
+ Anzeige +
+
+
+
+ + +
+
+
+
+
+ + + + Wenn Autoplay aktiviert ist, wird die Wiedergabe automatisch mit einem der aktuellen Videovorschläge fortgesetzt. + + + + + +
+

+ Nächstes Video +

+ + +
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ , um dieses Video zur Playlist "Später ansehen" + hinzuzufügen. + +
+
+
+

+ Hinzufügen +

+
+
+

+ + + + Playlists werden geladen... + +

+ +
+
+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/lib/src/test/resources/pages/youtube_consent_page.html.static b/lib/src/test/resources/pages/youtube_consent_page.html.static new file mode 100644 index 0000000..40b4235 --- /dev/null +++ b/lib/src/test/resources/pages/youtube_consent_page.html.static @@ -0,0 +1,160 @@ +Bevor Sie zu YouTube weitergehen
YouTube ein Google-Unternehmen

Bevor Sie zu YouTube weitergehen

Google verwendet Cookies und Daten, um Dienste und Werbung zur Verfügung zu stellen, zu verwalten und zu verbessern. Wenn Sie zustimmen, nutzen wir Cookies für diese Zwecke und dazu, Inhalte und Werbung für Sie zu personalisieren, damit Sie z. B. relevantere Google-Suchergebnisse und relevantere Werbung bei YouTube erhalten. Die Personalisierung erfolgt auf Grundlage Ihrer Aktivitäten, beispielsweise Ihrer Google-Suchanfragen und der Videos, die Sie sich bei YouTube ansehen. Wir verwenden diese Daten auch für Analysen und Messungen. Klicken Sie auf „Anpassen“, um sich weitere Optionen anzusehen, oder besuchen Sie g.co/privacytools. Darüber hinaus haben Sie die Möglichkeit, Ihre Browsereinstellungen so zu konfigurieren, dass einige oder alle Cookies blockiert werden.

\ No newline at end of file diff --git a/lib/src/test/resources/pages/youtube_consent_page_invalid.html.static b/lib/src/test/resources/pages/youtube_consent_page_invalid.html.static new file mode 100644 index 0000000..a3fbeae --- /dev/null +++ b/lib/src/test/resources/pages/youtube_consent_page_invalid.html.static @@ -0,0 +1,332 @@ + + + + + Bevor Sie zu YouTube weitergehen + + + + + +
YouTube ein Google-Unternehmen +
+

Bevor Sie zu YouTube weitergehen

+

Google verwendet Cookies + und Daten, um Dienste und Werbung zur Verfügung zu stellen, zu verwalten und zu verbessern. Wenn Sie zustimmen, + nutzen wir Cookies für diese Zwecke und dazu, Inhalte und Werbung für Sie zu personalisieren, damit Sie z. B. + relevantere Google-Suchergebnisse und relevantere Werbung bei YouTube erhalten. Die Personalisierung erfolgt auf + Grundlage Ihrer Aktivitäten, beispielsweise Ihrer Google-Suchanfragen und der Videos, die Sie sich bei YouTube + ansehen. Wir verwenden diese Daten auch für Analysen und Messungen. Klicken Sie auf „Anpassen“, um sich weitere + Optionen anzusehen, oder besuchen Sie g.co/privacytools. Darüber hinaus haben Sie die Möglichkeit, Ihre + Browsereinstellungen so zu konfigurieren, dass einige oder alle Cookies blockiert werden.

+
+ Anpassen +
+
+
+
+ + + \ No newline at end of file diff --git a/lib/src/test/resources/pages/youtube_malformed_captions_json.html.static b/lib/src/test/resources/pages/youtube_malformed_captions_json.html.static new file mode 100644 index 0000000..1ce5d52 --- /dev/null +++ b/lib/src/test/resources/pages/youtube_malformed_captions_json.html.static @@ -0,0 +1,4215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Surface Go Review - It’s Awesome - YouTube + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+ + +
+
+ +
+
+ +
+ DE +
+
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+

+ + + +Wird geladen... + +

+ +
+
+
+ +
+
+
+
+
+
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

+ + + + + Surface Go Review - Es ist Awesome + + +

+
+
+ + +
+
+ + + + + +
+ + +
+
+
+
+
1.597.128 Aufrufe
+
+
+
+
+
+ + + +
+
+
+ + +
+
+
+
+

+ + + +Wird geladen... + +

+ +
+
+
+ +
+ +
+
+
+

+ + + +Wird geladen... + +

+ +
+
+
+

+ Transkript +

+
+ +
+ + +
+
+ Das interaktive Transkript konnte nicht geladen werden. +
+ + +
+
+ +
+ +
+
+

+ + + +Wird geladen... + +

+ +
+
+ + +
+
+ Die Bewertungsfunktion ist nach Ausleihen des Videos + verfügbar. +
+ +
+ +
+
+ Diese Funktion ist gerade nicht verfügbar. Bitte versuche es später noch + einmal. +
+
+ + +
+ + +
+ + +
+
+
+
+
Am + 02.08.2018 veröffentlicht +
+
+

Dave2D-Rezension des Microsoft + Surface Go. Dies ist die beste 2 + in einem Laptop von Microsoft für Studenten mit einem strafferen + Budget.
Zum Verkauf hier-https://amzn.to/2n3Y4sj

Dieser + 2in1 tablet/Laptop ist unglaublich klein und hat eine Tonne + Potenzial für Menschen, die ein ultratragbares Gerät benötigen, + das sowohl als komfortables Tablet als auch als sehr + funktionstüchtig eingesetzt werden kann. Das ist toll für + Entwickler, Studenten, Arbeit oder auch für den Medienkonsum als + Sekundärgerät.

Musik-Credits:
Fili-Sonntag + Vibez

Folge mir:
http://twitter.com/Dave2D
http://www.instagram.com/Dave2D +

+
+
+ +
+
+
+
+ + +
+ + +
+
+

+ + + +Wird geladen... + +

+ +
+ +
+ + +
+
+
+ + +
+
+ +
+ +
+
+
+ Anzeige +
+
+
+
+ + +
+
+
+
+
+ + + + Wenn Autoplay aktiviert ist, wird die Wiedergabe automatisch mit einem der aktuellen Videovorschläge fortgesetzt. + + + + + +
+

+ Nächstes Video +

+ + +
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ , um dieses Video zur Playlist "Später ansehen" + hinzuzufügen. + +
+
+
+

+ Hinzufügen +

+
+
+

+ + + + Playlists werden geladen... + +

+ +
+
+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/lib/src/test/resources/pages/youtube_no_transcript_available.html.static b/lib/src/test/resources/pages/youtube_no_transcript_available.html.static new file mode 100644 index 0000000..54c9f0d --- /dev/null +++ b/lib/src/test/resources/pages/youtube_no_transcript_available.html.static @@ -0,0 +1,3014 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MTG Top 10: BAD Cards That Suddenly Became Good - YouTube + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+ + +
+
+ +
+
+ +
+ DE +
+
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+

+ + + +Wird geladen... + +

+ +
+
+
+ +
+
+
+
+
+
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

+ + + + + MTG Top 10: BAD Cards That Suddenly Became Good + + +

+
+
+ + +
+
+ + + + + +
+ + +
+
+
+
+
305.276 Aufrufe
+
+
+
+
+
+ + + +
+
+
+ + +
+
+
+
+

+ + + +Wird geladen... + +

+ +
+
+
+ +
+ +
+
+

+ + + +Wird geladen... + +

+ +
+
+ + +
+
+ Die Bewertungsfunktion ist nach Ausleihen des Videos + verfügbar. +
+ +
+ +
+
+ Diese Funktion ist gerade nicht verfügbar. Bitte versuche es später noch + einmal. +
+
+ + +
+ + +
+ + +
+
+
+
+
Am + 25.07.2019 veröffentlicht
+

This video is + sponsored by CardKingdom! Check out their awesome store here: http://www.cardkingdom.com/?utm_sourc...

Want + to see me draft live? You can on Twitch! http://www.twitch.tv/Nizzahon

Want + to support the channel? You can on Patreon!: https://www.patreon.com/Nizzahon_Magic

Follow + me on Twitter for channel updates and other Magic musings: https://twitter.com/NizzahonMagic

Animations + by Mike from Mythic Tales. Find his channel filled with awesome MTG + animation here: https://www.youtube.com/user/RadioCom...

I + Can Feel it Coming Kevin MacLeod (http://incompetech.com + )
Licensed under Creative Commons: By Attribution 3.0 + License
http://creativecommons.org/licenses/b... +

+
+
    +
  • +

    + Kategorie +

    + +
  • + +
+
+
+
+
+ + +
+ + +
+
+

+ + + +Wird geladen... + +

+ +
+ +
+ + +
+
+
+ + +
+
+ +
+ +
+
+
+ Anzeige +
+
+
+
+ + +
+
+
+
+
+ + + +Wenn Autoplay aktiviert ist, wird die Wiedergabe automatisch mit einem der aktuellen Videovorschläge fortgesetzt. + + + +
+

+ Nächstes Video +

+ + +
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ , um dieses Video zur Playlist "Später ansehen" hinzuzufügen. + +
+
+
+

+ Hinzufügen +

+
+
+

+ + + + Playlists werden geladen... + +

+ +
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/lib/src/test/resources/pages/youtube_no_translation.html.static b/lib/src/test/resources/pages/youtube_no_translation.html.static new file mode 100644 index 0000000..e6fc8a2 --- /dev/null +++ b/lib/src/test/resources/pages/youtube_no_translation.html.static @@ -0,0 +1,14562 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + اسهل طريقه لفك ضغط العاب الكمبيوتر | وتشغيل جميع الالعاب بدون مشاكل 🔥 - YouTube + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ + + + +
+
+
+
+
+
+ + +
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
+
+
+ + + + + + +
+
+
+
+
+
+ InfoPresseUrheberrechtKontaktCreatorWerbenEntwicklerImpressumVerträge hier kündigenNutzungsbedingungenDatenschutzRichtlinien + & SicherheitWie funktioniert YouTube?Neue Funktionen testen + +
+ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lib/src/test/resources/pages/youtube_too_many_requests.html.static b/lib/src/test/resources/pages/youtube_too_many_requests.html.static new file mode 100644 index 0000000..29799a7 --- /dev/null +++ b/lib/src/test/resources/pages/youtube_too_many_requests.html.static @@ -0,0 +1,251 @@ + + + + YouTube + + + + + + + + + +
+
+

+ Perdón por la interrupción. Hemos recibido un gran número de + solicitudes de tu red. +

+

+ Para seguir disfrutando de YouTube, rellena el siguiente formulario. +

+
+
+
+
+ +
+ ES + +
+
+ +
+ + diff --git a/lib/src/test/resources/pages/youtube_transcripts_disabled.html.static b/lib/src/test/resources/pages/youtube_transcripts_disabled.html.static new file mode 100644 index 0000000..2b2f2d4 --- /dev/null +++ b/lib/src/test/resources/pages/youtube_transcripts_disabled.html.static @@ -0,0 +1,3242 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Angèle - Eels x Richard Cocciante | A Take Away Show - YouTube + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+ + +
+
+ +
+
+ +
+ DE +
+
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+

+ + + +Wird geladen... + +

+ +
+
+
+ +
+
+
+
+
+
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ #Angèle #BrolLaSuite #Blogothèque + +
+

+ + + + + Angèle - Eels x Richard Cocciante | A Take Away Show + + +

+
+
+ + +
+
+ + + + + +
+ + +
+
+
+
+
491.364 Aufrufe
+
+
+
+
+
+ + + +
+
+
+ + +
+
+
+
+

+ + + +Wird geladen... + +

+ +
+
+
+ +
+ +
+
+

+ + + +Wird geladen... + +

+ +
+
+ + +
+
+ Die Bewertungsfunktion ist nach Ausleihen des Videos + verfügbar. +
+ +
+ +
+
+ Diese Funktion ist gerade nicht verfügbar. Bitte versuche es später noch + einmal. +
+
+ + +
+ + +
+ + +
+
+
+
+
Am + 28.11.2019 veröffentlicht +
+
+

Abonnez-vous ! http://bit.ly/SubBlogo
Retrouvez le + concert en intégralité sur CANAL+ via myCANAL : + http://bit.ly/2srC54F


La + Blogothèque & Off Productions
avec la participation de + Canal+

Filmé au Comptoir Général, Paris, en octobre + 2019
Réalisation: Xavier Reim
Directeur de la + photographie: Thibaut Charlut
Cadreur: Célidja + Pornon

Réalisation son: Jean-Baptiste Aubonnet & + Guillaume De La Villéon
Opérateur son: Alban + Lejeune

Producteur délégué: Christophe Abric
Producteur + Exécutif: Anousonne Savanchomkeo
Directeur de Production: + Rémi Veyrié

#Angèle + #BrolLaSuite + #Blogothèque

— + Follow La Blogothèque : +
http://blogotheque.net
http://facebook.com/blogotheque +
http://instagram.com/blogotheque +
http://twitter.com/blogotheque

— + Stay a while :
Take Away Shows, the Very Best : + http://bit.ly/TASBest
Take Away + Shows 2018 : http://bit.ly/TAShow18
Take + Away Shows 2017 : http://bit.ly/TAShow17
Take + Away Shows 2016 : http://bit.ly/TAShow16

For + more than ten years, La Blogotheque has changed the way people + experience music videos. We film beautiful, rare and intimate + sessions with your favorite artists, and the ones you are soon + to fall in love with. Come, stay a while, and be taken away.

+
+
+ +
+
+
+
+ + +
+ + +
+
+

+ + + +Wird geladen... + +

+ +
+ +
+ + +
+
+
+ + +
+
+ +
+ +
+
+
+ Anzeige +
+
+
+
+ + +
+
+
+
+
+ + + + Wenn Autoplay aktiviert ist, wird die Wiedergabe automatisch mit einem der aktuellen Videovorschläge fortgesetzt. + + + + + +
+

+ Nächstes Video +

+ + +
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ , um dieses Video zur Playlist "Später ansehen" hinzuzufügen. + +
+
+
+

+ Hinzufügen +

+
+
+

+ + + + Playlists werden geladen... + +

+ +
+
+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/lib/src/test/resources/pages/youtube_transcripts_disabled2.html.static b/lib/src/test/resources/pages/youtube_transcripts_disabled2.html.static new file mode 100644 index 0000000..216bf8d --- /dev/null +++ b/lib/src/test/resources/pages/youtube_transcripts_disabled2.html.static @@ -0,0 +1,10846 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Watch live Dow Jones feed: Markets plunge amid coronavirus fears, oil price war - YouTube + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+
+ +
+
+ + + + + + + + + + +
+
+ + + + + + +
+
+
+
+
+
+
+ AboutPressCopyrightContact + usCreatorsAdvertiseDevelopersTermsPrivacyPolicy & + SafetyHow YouTube worksTest new features + +
+ + + + + + + + + + +
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/lib/src/test/resources/pages/youtube_video_unavailable.html.static b/lib/src/test/resources/pages/youtube_video_unavailable.html.static new file mode 100644 index 0000000..07b8b9d --- /dev/null +++ b/lib/src/test/resources/pages/youtube_video_unavailable.html.static @@ -0,0 +1,1325 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + YouTube + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+ + +
+
+ +
+
+ +
+ DE +
+
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+

+ + + +Wird geladen... + +

+ +
+
+
+ +
+
+
+
+
+ +
+ +
+ +
+
+
+
+
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ , um dieses Video zur Playlist "Später ansehen" hinzuzufügen. + +
+
+
+

+ Hinzufügen +

+
+
+

+ + + + Playlists werden geladen... + +

+ +
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/lib/src/test/resources/transcript.xml b/lib/src/test/resources/transcript.xml new file mode 100644 index 0000000..b8375db --- /dev/null +++ b/lib/src/test/resources/transcript.xml @@ -0,0 +1,7 @@ + + + Hey, this is just a test + <b>this is not the original transcript</b> + + test & test, like this "test" he's testing + diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..28be9de --- /dev/null +++ b/renovate.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base", + ":dependencyDashboard", + ":semanticCommits", + "schedule:weekly" + ] +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..895891f --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,2 @@ +rootProject.name = "youtube-transcript-api" +include("lib") \ No newline at end of file